From e87d039e7a5ddc272a391cd38a7d4b9448538946 Mon Sep 17 00:00:00 2001 From: "@m6323510" Date: Mon, 26 Oct 2026 05:08:02 +0800 Subject: [PATCH] Allow deselecting chosen problem card on click Support toggling off a manually selected problem card by clicking it again. Releasing the active card clears manualProblem, keptDraw, and avoidedProblem, reapplies difficulty filters, and runs the normal settle path via applySuggestedLevel() and recommend(note) once history is ready. Guard against recommending while account updates or deletions are in flight. Restore focus properly when an unhidden card returns to view. Add comprehensive browser tests covering out-of-filter release, focus retention, due note restoration, release before history loads, and release after held history resolves with a suggested level. Closes #392 --- tests/browser/lobby.test.js | 265 ++++++++++++++++++++++++++++++++++++ web/app.js | 28 +++- 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 4096960e..2d418cc2 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -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"); + + // 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", + ); + 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) => { diff --git a/web/app.js b/web/app.js index a591c75a..e9e3b7f5 100644 --- a/web/app.js +++ b/web/app.js @@ -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) { + const hadFocus = document.activeElement === card.button; + manualProblem = false; + keptDraw = null; + avoidedProblem = undefined; + applyDifficulties(); + if (historyReady) { + 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); + } else { + setProblem(null); + 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) {