Repository navigation
Allow deselecting chosen problem card on click #455
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2281,6 +2281,271 @@ lobbyTest( | |
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "clicking an already selected problem card releases manual selection", | ||
| async (page) => { | ||
| const easyCard = EASY[0]; | ||
| await page.addInitScript( | ||
| ([key, id]) => localStorage.setItem(key, JSON.stringify([id])), | ||
| [PRACTICE_LIST_KEY, easyCard], | ||
| ); | ||
|
|
||
| await lobby(page); | ||
| await awaitReady(page); | ||
|
|
||
| const initial = await snapshot(page); | ||
|
|
||
| // Draw out-of-filter Easy card from saved practice list | ||
| await page.click("#practice-list-pick"); | ||
| assert.equal((await snapshot(page)).card, easyCard); | ||
|
|
||
| // Open problem picker so the card grid is visible in DOM | ||
| await page.click("details.problem-picker summary"); | ||
| const cardButton = page.locator(`[data-problem="${easyCard}"]`); | ||
| assert.equal( | ||
| await cardButton.isVisible(), | ||
| true, | ||
| "selected card must be visible as an exception", | ||
| ); | ||
|
|
||
| // Activate the card button via keyboard while it holds focus | ||
| await cardButton.focus(); | ||
| await page.keyboard.press("Enter"); | ||
|
|
||
| const after = await snapshot(page); | ||
| assert.notEqual(after.card, easyCard); | ||
| assert.match(after.note, /^Selected problem:/); | ||
| assert.equal( | ||
| await page.locator(`[data-problem="${after.card}"]`).isVisible(), | ||
| true, | ||
| "the redrawn card must be shown in the grid", | ||
| ); | ||
| assert.equal( | ||
| after.duration, | ||
| initial.duration, | ||
| "releasing manual override must restore duration", | ||
| ); | ||
| assert.equal( | ||
| await cardButton.isVisible(), | ||
| false, | ||
| "out-of-filter card must be hidden after deselection", | ||
| ); | ||
|
|
||
| // Focus must move to problem-picker summary when deselected card hides | ||
| assert.equal( | ||
| await page.evaluate( | ||
| () => | ||
| document.activeElement === | ||
| document.querySelector("details.problem-picker summary"), | ||
| ), | ||
| true, | ||
| "focus must move to problem-picker summary when deselected card hides", | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "deselecting a recommended problem card preserves the card but restores the recommendation note", | ||
| async (page) => { | ||
| const dueProblem = EASY[0]; | ||
| reports = [savedAttempt(dueProblem)]; | ||
|
|
||
| await lobby(page); | ||
| await setLevel(page, "Easy", true); | ||
| await setLevel(page, "Medium", false); | ||
| await awaitReady(page); | ||
|
|
||
| const initial = await snapshot(page); | ||
| assert.equal(initial.card, dueProblem); | ||
| assert.match(initial.note, /Review due after/); | ||
|
|
||
| // Open picker and manually select the recommended card | ||
| await page.click("details.problem-picker summary"); | ||
| await page.click(`[data-problem="${dueProblem}"]`); | ||
|
|
||
| const selected = await snapshot(page); | ||
| assert.equal(selected.card, dueProblem); | ||
| assert.match(selected.note, /Selected problem:/); | ||
|
|
||
| // Click it again to deselect | ||
| await page.click(`[data-problem="${dueProblem}"]`); | ||
|
|
||
| const after = await snapshot(page); | ||
| assert.equal( | ||
| after.card, | ||
| dueProblem, | ||
| "card is unchanged when recommendation lands on the same problem", | ||
| ); | ||
| assert.match( | ||
| after.note, | ||
| /Review due after/, | ||
| "note restores to review due explanation", | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "releasing an in-filter card keeps focus on its button", | ||
| async (page) => { | ||
| await lobby(page); | ||
| await awaitReady(page); | ||
|
|
||
| const initial = await snapshot(page); | ||
|
|
||
| // Pick an in-filter card that differs from the default recommendation | ||
| const otherCard = MEDIUM.find((id) => id !== initial.card) ?? MEDIUM[1]; | ||
| const cardButton = page.locator(`[data-problem="${otherCard}"]`); | ||
|
|
||
| await page.click("details.problem-picker summary"); | ||
| await cardButton.click(); | ||
|
|
||
| const manualPick = await snapshot(page); | ||
| assert.equal(manualPick.card, otherCard); | ||
|
|
||
| // Focus the button and release via keyboard | ||
| await cardButton.focus(); | ||
| await page.keyboard.press("Enter"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test only asserts focus, so it still passes if deselection is broken: |
||
|
|
||
| // 1. Focus assertion | ||
| assert.equal( | ||
| await page.evaluate( | ||
| (id) => | ||
| document.activeElement === | ||
| document.querySelector(`[data-problem="${id}"]`), | ||
| otherCard, | ||
| ), | ||
| true, | ||
| "focus must remain on button when the released card stays visible", | ||
| ); | ||
|
|
||
| // 2. Deselection assertion: state reverts to the recommendation | ||
| const released = await snapshot(page); | ||
| assert.equal( | ||
| released.card, | ||
| initial.card, | ||
| "selected card must revert to initial recommendation", | ||
| ); | ||
| assert.equal( | ||
| released.note, | ||
| initial.note, | ||
| "recommendation note must revert to initial recommendation", | ||
| ); | ||
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "releasing a manual pick before history loads clears selection and disables start", | ||
| async (page) => { | ||
| const release = await heldLobby(page); | ||
| try { | ||
| await page.click("details.problem-picker summary"); | ||
| const card = MEDIUM[0]; | ||
| const cardButton = page.locator(`[data-problem="${card}"]`); | ||
|
|
||
| // Manually select a problem before history completes | ||
| await cardButton.click(); | ||
|
|
||
| const selected = await snapshot(page); | ||
| assert.equal(selected.card, card); | ||
| assert.equal(selected.startDisabled, false); | ||
|
|
||
| // Deselect the card while history is still held | ||
| await cardButton.click(); | ||
|
|
||
| const released = await snapshot(page); | ||
| assert.equal(released.note, "", "recommendation note must be empty"); | ||
| assert.equal( | ||
| released.startDisabled, | ||
| true, | ||
| "Start button must be disabled", | ||
| ); | ||
| assert.equal( | ||
| await page.locator('[data-problem][aria-pressed="true"]').count(), | ||
| 0, | ||
| "no problem card should be pressed after releasing before history loads", | ||
| ); | ||
| } finally { | ||
| release(); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "releasing a card after held history resolves applies the suggested level", | ||
| async (page) => { | ||
| reports = [missed(MEDIUM[0]), missed(MEDIUM[1])]; | ||
| const release = await heldLobby(page); | ||
| try { | ||
| await page.click("details.problem-picker summary"); | ||
| const card = MEDIUM[0]; | ||
| const cardButton = page.locator(`[data-problem="${card}"]`); | ||
|
|
||
| // Manually pick a card before history finishes loading | ||
| await cardButton.click(); | ||
| assert.equal((await snapshot(page)).card, card); | ||
|
|
||
| // Let history resolve while the card is manually selected | ||
| release(); | ||
| await reaches( | ||
| page, | ||
| () => !document.querySelector("#random-problem").disabled, | ||
| ); | ||
| // Deselect the card after held history has settled | ||
| await cardButton.click(); | ||
|
|
||
| const released = await snapshot(page); | ||
| assert.deepEqual( | ||
| released.levels, | ||
| ["Easy"], | ||
| "Suggested difficulty level from resolved history must be applied", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This assertion cannot fail: Medium is the default checked level, and two Easy hires suggest Medium, so |
||
| ); | ||
| assert.equal( | ||
| released.startDisabled, | ||
| false, | ||
| "Start button must be enabled once recommendation settles", | ||
| ); | ||
| } finally { | ||
| release(); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "releasing a card before history loads moves focus to summary if card gets hidden", | ||
| async (page) => { | ||
| const errors = []; | ||
| page.on("pageerror", (error) => errors.push(error.message)); | ||
| reports = [missed(MEDIUM[0]), missed(MEDIUM[1])]; | ||
| const release = await heldLobby(page); | ||
| try { | ||
| await page.click("details.problem-picker summary"); | ||
| const cardButton = page.locator(`[data-problem="${MEDIUM[0]}"]`); | ||
|
|
||
| await cardButton.click(); | ||
| await cardButton.click(); | ||
| assert.equal( | ||
| await isFocused(cardButton), | ||
| true, | ||
| "focus should still be on the released card before history loads", | ||
| ); | ||
|
|
||
| release(); | ||
| await awaitReady(page); | ||
|
|
||
| assert.deepEqual((await snapshot(page)).levels, ["Easy"]); | ||
| assert.equal(await cardButton.isHidden(), true); | ||
| assert.equal( | ||
| await isFocused(page.locator("details.problem-picker summary")), | ||
| true, | ||
| "focus must move to the picker summary when the focused card is hidden", | ||
| ); | ||
| assert.deepEqual(errors, []); | ||
| } finally { | ||
| release(); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| lobbyTest( | ||
| "opening the picker preserves a due review outside the selected difficulty", | ||
| async (page) => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -292,7 +292,9 @@ function selectedEditorOptions() { | |||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // A picked card is a choice about this one interview, not about the filter the | ||||||||||||||
| // checkboxes carry, so it leaves them alone. It suggests a length to go with | ||||||||||||||
| // checkboxes carry, so it leaves them alone. Clicking the currently chosen card | ||||||||||||||
| // again toggles it off, releasing the manual selection and falling back to the | ||||||||||||||
| // default recommendation. When selecting a card, it suggests a length to go with | ||||||||||||||
| // the level it belongs to, which `setDuration` applies only if the candidate | ||||||||||||||
| // has not already chosen one. | ||||||||||||||
| for (const card of cards) { | ||||||||||||||
|
|
@@ -309,6 +311,25 @@ for (const card of cards) { | |||||||||||||
| syncLobbyControls(); | ||||||||||||||
| }); | ||||||||||||||
| card.button.addEventListener("click", () => { | ||||||||||||||
| if (manualProblem && problem === card) { | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The deselect branch calls
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The release branch calls
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||
| const hadFocus = document.activeElement === card.button; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||
| manualProblem = false; | ||||||||||||||
| keptDraw = null; | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Releasing the pick never reapplies the level filters, so whatever the pick changed stays changed. Say "My list" draws an Easy card while only Medium is checked. The draw sets the duration to 30 and unhides the card as a
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
| avoidedProblem = undefined; | ||||||||||||||
| applyDifficulties(); | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deselecting an out-of-filter card (for example one drawn from the practice list while the checkboxes select another level) makes |
||||||||||||||
| if (historyReady) { | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Releasing a pick made before history loaded recommends from stale checkboxes. Once |
||||||||||||||
| const note = manualDifficulty ? "" : applySuggestedLevel(); | ||||||||||||||
| // Releasing a manual pick falls back to deterministic recommendation. | ||||||||||||||
| // If the card is due or the only match, it intentionally stays selected. | ||||||||||||||
| recommend(note); | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the default recommendation lands on the card being released (a due review, or the only match for the current filters), the click ends with the same card selected and
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Releasing falls back to |
||||||||||||||
| } else { | ||||||||||||||
| setProblem(null); | ||||||||||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||||||||||
| nodes.recommendation.textContent = ""; | ||||||||||||||
| } | ||||||||||||||
| if (card.button.hidden) focusPickerSummary(); | ||||||||||||||
| else if (hadFocus) card.button.focus(); | ||||||||||||||
| return; | ||||||||||||||
| } | ||||||||||||||
| // setProblem clears the exception; restore it when reselecting. | ||||||||||||||
| const wasException = searchVisibilityException === card; | ||||||||||||||
| manualProblem = true; | ||||||||||||||
|
|
@@ -941,6 +962,9 @@ function refreshHistory() { | |||||||||||||
| /// describing history the page had already replaced. | ||||||||||||||
| function settle() { | ||||||||||||||
| historyReady = true; | ||||||||||||||
| const focusedCard = cards.find( | ||||||||||||||
| (card) => card.button === document.activeElement, | ||||||||||||||
| ); | ||||||||||||||
| // A back/forward restore reapplies the filters. Keep an explicit choice | ||||||||||||||
| // visible, whether selected by card, practice draw or history's Try again. | ||||||||||||||
| // A non-empty title search still owns card visibility during account refresh. | ||||||||||||||
|
|
@@ -959,6 +983,8 @@ function settle() { | |||||||||||||
| // still stands, so the button the account refresh held down needs releasing | ||||||||||||||
| // here rather than there. | ||||||||||||||
| syncLobbyControls(); | ||||||||||||||
| // If the card that held focus got hidden, recover focus to the picker summary | ||||||||||||||
| if (focusedCard?.button.hidden) focusPickerSummary(); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function loadAccount(generation) { | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The accepted scope for this change asks for the case where the recommendation lands on the same card as the one just released, and no test here reaches it: the first test releases an Easy card and gets a Medium one, and the second holds the history so
recommend()never runs. Seed a due review withsavedAttempt(due), click that card (the note becomesSelected problem: ...), click it again, and assert the card is unchanged but the note now readsReview due after. That is what tells a released pick from a kept one when the same card comes back.