From 0d0a2651a19b009295454e9fee894b3c2fd9e2db Mon Sep 17 00:00:00 2001 From: Ericko00o Date: Sun, 11 Oct 2026 12:13:58 +0800 Subject: [PATCH] Separate random draws from review recommendations Overdue reviews can monopolize explicit random selections and prevent other eligible problems from being offered. Give each problem matching the selected levels and topic an equal chance, avoiding the current problem when alternatives exist. Keep review priority for automatic recommendations and identify the randomly selected problem's level. Closes #210 --- tests/browser/lobby.test.js | 66 ++++++++++++++++++++++++++-- tests/browser/problem-picker.test.js | 64 +++++++++++++++++++++++++++ web/app.js | 10 ++++- web/problem-picker.js | 11 +++++ 4 files changed, 146 insertions(+), 5 deletions(-) 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(