diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 2f82c9e6..9c4bc31d 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -1050,20 +1050,84 @@ lobbyTest( ); lobbyTest( - "changing difficulty clears the previous random-pick exclusion", + "a drawn problem survives a difficulty change that still includes it", async (page) => { await page.addInitScript(() => { Math.random = () => 0; }); - const before = await lobby(page); + await lobby(page); await page.click("#random-problem"); - assert.notEqual((await snapshot(page)).card, before.card); - await setLevel(page, "Hard", true); - await setLevel(page, "Hard", false); - assert.equal((await snapshot(page)).card, before.card); + const drawn = await snapshot(page); + const level = (await cardInfo(page, drawn.card)).level; + + const others = ["Easy", "Medium", "Hard"].filter((name) => name !== level); + for (const other of others) { + await setLevel(page, other, true); + assert.equal( + (await snapshot(page)).card, + drawn.card, + `checking ${other} redrew a problem it did not hide`, + ); + } + // Returning from the media preflight re-runs the recommendation, and the + // roll that drew this problem names a different one under the new filter. + await restore(page); + await awaitReady(page); + assert.equal( + (await snapshot(page)).card, + drawn.card, + "a kept problem moved when the page was restored", + ); + await setLevel(page, others[0], false); + assert.equal( + (await snapshot(page)).card, + drawn.card, + `unchecking ${others[0]} redrew a problem it did not hide`, + ); + + await setLevel(page, level, false); + const redrawn = await snapshot(page); + assert.notEqual(redrawn.card, drawn.card, "a hidden problem stayed picked"); + const info = await cardInfo(page, redrawn.card); + assert.equal(info.level, others[1]); + assert.equal(info.hidden, false); }, ); +// Widening the filter keeps the card on screen, but it was drawn from the +// reports in hand, so refreshed ones that pass it decide again. The usual order +// widens before leaving for the interview; the other widens inside the reload, +// while the reports in hand still belong to the page before the candidate left. +for (const [when, widenFirst] of [ + ["before the interview", true], + ["while the history reloads", false], +]) { + lobbyTest( + `a drawn problem kept ${when} is redrawn from the history it comes back to`, + async (page) => { + const shown = (await lobby(page)).card; + const widen = async () => { + await setLevel(page, "Hard", true); + assert.equal((await snapshot(page)).card, shown); + }; + + if (widenFirst) await widen(); + const release = holdHistory(); + reports = [hired(shown)]; + await restore(page); + if (!widenFirst) await widen(); + + release(); + await awaitReady(page); + assert.notEqual( + (await snapshot(page)).card, + shown, + "a problem the refreshed history passed stayed recommended", + ); + }, + ); +} + lobbyTest( "Random problem waits for history on load and browser restore", async (page) => { diff --git a/web/app.js b/web/app.js index 7a90d634..49adca21 100644 --- a/web/app.js +++ b/web/app.js @@ -35,6 +35,11 @@ let reports = []; /// The practice focus the share box currently refers to. let sharedFocus = null; let manualProblem = false; +/// The reports a drawn problem kept across a difficulty change was drawn from, +/// serialized, or null when nothing is kept. Not `manualProblem`: a hand pick +/// stands for good, while a kept draw stands only until the history it was +/// drawn from changes. +let keptDraw = null; let manualDuration = false; let manualDifficulty = false; let historyReady = false; @@ -176,6 +181,7 @@ for (const card of cards) { nodes.randomProblem.addEventListener("click", () => { if (!historyReady || accountUpdatePending() || deletingReports) return; manualProblem = false; + keptDraw = null; roll = Math.random(); avoidedProblem = problem?.id; applyDifficulties(); @@ -192,11 +198,20 @@ for (const input of levels) { } manualDifficulty = true; applyDifficulties(); - // A pick made by hand survives a filter that still includes it. Only one - // that now hides it hands the choice back to the lobby. - if (manualProblem && selectedDifficulties().has(problem?.difficulty)) + // A problem survives a filter that still includes it, whether it was + // picked by hand or drawn: the checkbox answered which levels to offer, + // not whether to throw away the problem on screen. Only a filter that now + // hides it hands the choice back to the lobby. + if (selectedDifficulties().has(problem?.difficulty)) { + // Kept, a drawn problem is no longer what `roll` draws under this + // filter, so the next `settle` would swap it. Held against the reports + // in hand, which while the history is still loading are the ones about + // to be replaced, so `settle` redraws then. + if (!manualProblem) keptDraw = JSON.stringify(reports); return; + } manualProblem = false; + keptDraw = null; roll = Math.random(); avoidedProblem = undefined; // Not before the reports are in. Recommending from an empty history here @@ -535,6 +550,10 @@ function refreshHistory() { /// describing history the page had already replaced. function settle() { historyReady = true; + // Refreshed reports that differ from the ones a kept draw came from may have + // just recorded it as passed, so the lobby draws again. + if (keptDraw !== null && keptDraw !== JSON.stringify(reports)) + keptDraw = null; // The level suggestion only applies when the candidate has not already said // what they want. Moving their checkboxes would also hide the card they just // picked. @@ -880,8 +899,9 @@ function applyDifficulties() { function recommend(note = "") { // A candidate who picked a card has answered the question this line asks, so // it stays answered. Naming a different problem here contradicted the card - // they had just selected. - if (manualProblem) return; + // they had just selected. A kept draw stands the same way until `settle` + // drops it. + if (manualProblem || keptDraw !== null) return; const choice = pickProblem( cards, selectedDifficulties(),