Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2281,6 +2281,271 @@ lobbyTest(
},
);

lobbyTest(
"clicking an already selected problem card releases manual selection",

Copy link
Copy Markdown
Contributor

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 with savedAttempt(due), click that card (the note becomes Selected problem: ...), click it again, and assert the card is unchanged but the note now reads Review due after. That is what tells a released pick from a kept one when the same card comes back.

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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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: cardButton.click() already focuses the button, and with the old handler the second Enter re-selects the same card and keeps focus. Also assert that the manual pick was released: choose an in-filter card that differs from the recommendation, press Enter again, and check that snapshot(page).card and note return to the recommendation.


// 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 released.levels is ["Medium"] even if the release path never calls applySuggestedLevel(). Use history that moves the level off the default (for example two missed(MEDIUM[0])-style reports that suggest Easy) and assert the changed level, so the test fails when the suggestion step is dropped.

);
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) => {
Expand Down
28 changes: 27 additions & 1 deletion web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -309,6 +311,25 @@ for (const card of cards) {
syncLobbyControls();
});
card.button.addEventListener("click", () => {
if (manualProblem && problem === card) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deselect branch calls recommend() with none of the guards the other pickers use. nodes.randomProblem and nodes.practiceListPick return early on accountUpdatePending() and deletingReports, and accountUpdatePending() can be true while historyReady is still true (historyRefreshing, loginPending). Deselecting during a login or history refresh therefore draws from the previous account's reports, and during a bulk delete from reports that are about to disappear. Return early under the same conditions, or at least when accountUpdatePending() || deletingReports || starting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The release branch calls recommend() (and setProblem(null)) without the accountUpdatePending() || deletingReports guard that randomProblem and practiceListPick both use. Card buttons are not disabled in those states (syncLobbyControls only disables the fieldset while starting), so clicking the selected card during a report delete or a login/history refresh redraws against reports that are about to change, and the completion path redraws again. Return early on that condition before touching manualProblem.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nodes.randomProblem and nodes.practiceListPick bail out on accountUpdatePending(), but this branch only checks historyReady. During a login or account refresh, releasing a pick runs recommend against the stale reports, then settle runs it again on the refreshed ones, so the card can change twice under the user. Consider returning early while accountUpdatePending(), as the random button does.

const hadFocus = document.activeElement === card.button;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hadFocus is assigned but never read. The release path then calls card.button.focus() unconditionally, so a release that did not start on this button (for example one triggered by assistive technology without moving focus) pulls focus onto it. Either use it, else if (hadFocus) card.button.focus();, or drop the variable.

manualProblem = false;
keptDraw = null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 searchVisibilityException. Click that card to release it: recommend() picks a Medium problem, but the duration stays at 30. And because setProblem clears the exception without hiding the card, the Easy card stays in the picker even though the filters exclude it. The random button and the level checkboxes avoid this by calling applyDifficulties() first, which reapplies the filters and resets the suggested duration (it already leaves a duration the candidate chose alone):

Suggested change
keptDraw = null;
keptDraw = null;
applyDifficulties();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avoidedProblem survives the release. After Random, avoidedProblem holds the id of the problem Random replaced (set in the randomProblem handler), and only filterSelectionChanged clears it. Take a due review, click Random, pick a card by hand, then click it again: recommend() still excludes the due review, so you get fresh practice instead of the default recommendation this change promises. Clear it with the other resets:

Suggested change
keptDraw = null;
keptDraw = null;
avoidedProblem = undefined;

avoidedProblem = undefined;
applyDifficulties();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 applyDifficulties() hide the very button that has focus, so keyboard focus falls back to body and a keyboard user loses their place in the picker. Move focus to a visible control before hiding it, such as the details.problem-picker summary, when document.activeElement is card.button. The new out-of-filter test only clicks, so it could also activate the card by keyboard and assert where focus lands.

if (historyReady) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Releasing a pick made before history loaded recommends from stale checkboxes. settle() skips applySuggestedLevel() while manualProblem is true, and this branch clears manualProblem without applying the suggestion. So if a candidate picks a card while history is loading, history arrives, and they click the card again, recommend() runs against the default levels instead of the level their reports point at. A later account refresh can then move both the checkboxes and the problem.

Once historyReady, take the normal settle path: const note = manualDifficulty ? "" : applySuggestedLevel(); recommend(note);. Please add a test that releases after the held history resolves with a streak that changes the level; the added test only releases before history resolves.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 aria-pressed still true. Only the note text changes. Because roll is not redrawn, this is deterministic, so the click can look like a no-op. If that is intended, a short comment here saying so would help. If not, passing the released card as avoidedProblem (as the random button does) would force a different pick.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Releasing falls back to recommend, which keeps roll and clears avoidedProblem. When the recommendation is the same card (a due review, the only match, or the pick was already the recommended one), the button stays aria-pressed="true" and only the note changes, so a second press looks like it did nothing, especially to a screen reader user. Follow-up worth tracking: re-roll with avoidedProblem = card.id when the pool has another candidate, or say so in the note (e.g. "Selection cleared.").

} else {
setProblem(null);
Comment thread
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;
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down