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
66 changes: 63 additions & 3 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
},
);
}
Expand Down Expand Up @@ -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/);
},
);
64 changes: 64 additions & 0 deletions tests/browser/problem-picker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
10 changes: 8 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ nodes.randomProblem.addEventListener("click", () => {
roll = Math.random();
avoidedProblem = problem?.id;
applyDifficulties();
recommend();
recommend("", "random");
});

for (const input of levels) {
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand All @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions web/problem-picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down