diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 19098cb8..a9f00ed7 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -1516,7 +1516,10 @@ lobbyTest( const title = await page .locator(`[data-problem="${first.card}"] .problem-title`) .textContent(); - assert.equal(first.note, `Selected problem: ${title}.`); + assert.equal( + first.note, + `Selected problem: ${title} (${(await cardInfo(page, first.card)).level}).`, + ); assert.equal(first.card, eligible[1]); assert.deepEqual(first.levels, ["Medium", "Hard"]); assert.equal(first.duration, "60"); @@ -1700,9 +1703,12 @@ for (const first of [0, 1]) { await page.click(`[data-problem="${MEDIUM[1]}"]`); assert.equal((await snapshot(page)).card, MEDIUM[1]); await page.click("#random-problem"); - assert.equal((await snapshot(page)).card, MEDIUM[0]); + const drawn = await snapshot(page); + assert.notEqual(drawn.card, MEDIUM[1]); + assert.equal((await cardInfo(page, drawn.card)).level, "Medium"); + assert.doesNotMatch(drawn.note, /Review due/); await page.click("#random-problem"); - assert.notEqual((await snapshot(page)).card, MEDIUM[0]); + assert.notEqual((await snapshot(page)).card, drawn.card); }, ); } @@ -4588,3 +4594,57 @@ lobbyTest( } }, ); + +lobbyTest( + "a difficulty change from a fresh lobby retains review priority", + async (page) => { + reports = [savedAttempt(EASY[0]), savedAttempt(EASY[1])]; + const initial = await lobby(page); + assert.deepEqual(initial.levels, ["Medium"]); + assert.equal((await cardInfo(page, initial.card)).level, "Easy"); + assert.match(initial.note, /Review due/); + await page.evaluate(() => { + Math.random = () => 0; + }); + await setLevel(page, "Hard", true); + await setLevel(page, "Medium", false); + const changed = await snapshot(page); + assert.deepEqual(changed.levels, ["Hard"]); + assert.ok(EASY.includes(changed.card)); + assert.match(changed.note, /Review due/); + assert.equal((await cardInfo(page, changed.card)).hidden, false); + }, +); + +lobbyTest( + "a topic change after a random draw restores review priority", + async (page) => { + const due = pageOf("valid-parentheses"); + reports = [savedAttempt(due)]; + await lobby(page); + await page.click("#random-problem"); + assert.doesNotMatch((await snapshot(page)).note, /Review due/); + await openTopicFilter(page); + await page.selectOption("#problem-topic", "Array"); + const changed = await snapshot(page); + assert.equal(changed.card, due); + assert.match(changed.note, /Review due/); + assert.equal((await cardInfo(page, due)).hidden, false); + }, +); + +lobbyTest( + "an explicit random draw escapes overdue reviews at the selected level", + async (page) => { + reports = [savedAttempt(EASY[0]), savedAttempt(EASY[1])]; + const initial = await lobby(page); + assert.match(initial.note, /Review due/); + await page.evaluate(() => { + Math.random = () => 0; + }); + await page.click("#random-problem"); + const drawn = await snapshot(page); + assert.equal((await cardInfo(page, drawn.card)).level, "Medium"); + assert.doesNotMatch(drawn.note, /Review due/); + }, +); diff --git a/tests/browser/problem-picker.test.js b/tests/browser/problem-picker.test.js index 7cbdebb5..ce16a296 100644 --- a/tests/browser/problem-picker.test.js +++ b/tests/browser/problem-picker.test.js @@ -25,6 +25,52 @@ const completed = (problemId, at) => ({ const first = () => 0; const day = 24 * 60 * 60 * 1000; +test("explicit random draws reach every eligible problem despite overdue reviews", () => { + const problems = [ + { id: "due-a", difficulty: "Easy" }, + { id: "due-b", difficulty: "Easy" }, + { id: "unseen", difficulty: "Easy" }, + { id: "outside", difficulty: "Hard" }, + ]; + const reports = ["due-a", "due-b", "outside"].map((id) => completed(id, 0)); + for (const avoid of [undefined, "due-a", "due-b", "unseen"]) { + const expected = problems.filter( + (problem) => problem.difficulty === "Easy" && problem.id !== avoid, + ); + for (let index = 0; index < expected.length; index++) { + const choice = pickProblem( + problems, + new Set(["Easy"]), + reports, + () => (index + 0.5) / expected.length, + 10 * day, + avoid, + undefined, + "random", + ); + assert.equal(choice.picked.id, expected[index].id); + assert.equal(choice.review, null); + assert.equal(choice.repeat, false); + } + } +}); + +test("explicit random draws retain a sole eligible problem and respect empty filters", () => { + const draw = (levels) => + pickProblem( + bank, + new Set(levels), + [completed("passed", 0)], + first, + 10 * day, + "medium", + undefined, + "random", + ); + assert.equal(draw(["Medium"]).picked.id, "medium"); + assert.equal(draw([]), null); +}); + test("a problem already passed is not what gets recommended next", () => { const choice = pickProblem(bank, new Set(["Easy"]), [hired("passed")], first); assert.equal(choice.picked.id, "fresh"); @@ -614,3 +660,21 @@ test("the interview reads the focus from session storage, never from its address assert.match(source, /practiceFocus: consumeSharedFocus\(tabStorage\)/); assert.match(source, /const tabStorage = storageArea\("sessionStorage"\);/); }); + +test("random draws respect narrowed practice while reviews keep their broader pool", () => { + const reports = [completed("passed", 0)]; + const practice = bank.filter((problem) => problem.id === "fresh"); + const draw = (mode) => + pickProblem( + practice, + new Set(["Easy"]), + reports, + first, + 10 * day, + undefined, + bank, + mode, + ); + assert.equal(draw("recommend").picked.id, "passed"); + assert.equal(draw("random").picked.id, "fresh"); +}); diff --git a/web/app.js b/web/app.js index f2d01a89..ac25a5f4 100644 --- a/web/app.js +++ b/web/app.js @@ -254,7 +254,7 @@ nodes.randomProblem.addEventListener("click", () => { roll = Math.random(); avoidedProblem = problem?.id; applyDifficulties(); - recommend(); + recommend("", "random"); }); for (const input of levels) { @@ -1029,7 +1029,7 @@ function filterSelectionChanged() { /// `note` is the sentence explaining a level the reports chose, empty when the /// candidate chose it themselves and so already knows. -function recommend(note = "") { +function recommend(note = "", mode = "recommend") { // 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. A kept draw stands the same way until `settle` @@ -1043,6 +1043,7 @@ function recommend(note = "") { undefined, avoidedProblem, cards, + mode, ); // Nothing to offer is still an answer, and it has to go through `setProblem` // like every other one. Returning here left whatever was picked for the @@ -1054,6 +1055,11 @@ function recommend(note = "") { return; } setProblem(choice.picked); + // A random draw names the level rather than a review interval or streak. + if (mode === "random") { + nodes.recommendation.textContent = `${note}Selected problem: ${title(choice.picked)} (${choice.picked.difficulty}).`; + return; + } if (choice.review) { // A review can fall outside the levels now selected, so the card is // unhidden and the level said out loud rather than silently ignored. diff --git a/web/problem-picker.js b/web/problem-picker.js index 7c658570..792382dd 100644 --- a/web/problem-picker.js +++ b/web/problem-picker.js @@ -149,6 +149,8 @@ function step(level, by) { /// An explicit redraw avoids the current problem whenever another is available. /// `reviewProblems` stays broader than `problems` when an opt-in filter narrows /// new practice: a due review remains the first priority across those filters. +/// `random` mode draws uniformly from the selected levels instead of giving +/// overdue reviews or unseen problems priority. export function pickProblem( problems, difficulties, @@ -157,10 +159,19 @@ export function pickProblem( now = Date.now(), avoid, reviewProblems = problems, + mode = "recommend", ) { const eligible = problems.filter((problem) => difficulties.has(problem.difficulty), ); + // Explicit random draws give every problem at the selected levels a chance, + // regardless of its attempt history or review schedule. + if (mode === "random") { + const alternatives = eligible.filter((problem) => problem.id !== avoid); + const choices = alternatives.length ? alternatives : eligible; + const picked = choices[Math.floor(random() * choices.length)]; + return picked ? { picked, repeat: false, review: null } : null; + } const reportList = Array.isArray(reports) ? reports : []; const reviews = reviewStatus(reportList, now); const passed = new Set(