Skip to content
Open
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
18 changes: 10 additions & 8 deletions scripts/gen-problem-cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,24 @@ def parse_args() -> argparse.Namespace:


def read_json(path: Path) -> object:
return json.loads(path.read_text())
return json.loads(path.read_text(encoding="utf-8"))


def card(problem: dict[str, object], page: str, title: str) -> str:
"""One lobby card, named the way the interview will name the exercise.

Keyed by page name, with the scenario title and no topic tags: the
Keyed by page name, with the scenario title and topic metadata: the
recommendation line reads the title off this card, and "Recommended: Coin
Change", a `coin-change` key or a "Dynamic Programming" tag tells the
candidate what they are about to be asked. The published title is not in
the page at all; the source slot is filled from the page map only when the
candidate turns that on to drill one problem by name.
candidate what they are about to be asked. Topics stay in data attributes
for local filtering and are not rendered on the card. The published title
is not in the page at all; the source slot is filled from the page map only
when the candidate turns that on to drill one problem by name.
"""
topics = "|".join(str(topic) for topic in problem.get("topics", []))
return "\n".join(
[
f' <button class="problem-card" type="button" data-problem="{html.escape(page)}" data-difficulty="{html.escape(problem["difficulty"])}">',
f' <button class="problem-card" type="button" data-problem="{html.escape(page)}" data-difficulty="{html.escape(problem["difficulty"])}" data-topics="{html.escape(topics)}">',

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 reverses the rule the docstring above still states: a topic tag tells the candidate what they are about to be asked. data-topics ships the full problem-to-technique mapping in the page, so anyone who opens devtools on the recommended card sees "Dynamic Programming" before the interview starts. Either get the maintainers to agree to that change and reword the docstring to say topics are now exposed, or fetch the mapping only when the candidate actually uses the topic filter, the way the source slot is filled from the page map only on opt-in.

f' <span class="problem-title">{html.escape(title)}</span>',
' <span class="problem-source" hidden></span>',
f' <span class="problem-meta">{html.escape(problem["difficulty"])}</span>',
Expand All @@ -79,7 +81,7 @@ def generated(problems: list) -> str:

def main() -> int:
args = parse_args()
html_text = INDEX.read_text()
html_text = INDEX.read_text(encoding="utf-8")
opening = re.search(
rf"^([ \t]*){re.escape(START)}[ \t]*$", html_text, flags=re.MULTILINE
)
Expand Down Expand Up @@ -113,7 +115,7 @@ def main() -> int:
return 0

if updated != html_text:
INDEX.write_text(updated)
INDEX.write_text(updated, encoding="utf-8")
print(f"updated {len(problems)} problem cards in web/index.html")
return 0

Expand Down
15 changes: 14 additions & 1 deletion tests/browser/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,20 @@ export function installDocument(markup) {
// `#start` and `button#start` render into two different places, which is a
// page a browser cannot produce.
const node = (element) => {
if (!nodes.has(element)) nodes.set(element, new Element(element.tag));
if (!nodes.has(element)) {
const created = new Element(element.tag);
for (const [name, value] of Object.entries(element.attributes)) {
if (name.startsWith("data-")) {
const key = name
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
created.dataset[key] = value;
}
}
if ("value" in element.attributes)
created.value = element.attributes.value;
nodes.set(element, created);
}
return nodes.get(element);
};
globalThis.document = {
Expand Down
9 changes: 8 additions & 1 deletion tests/browser/lobby-render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const SIGNED_OUT = {
start: "Start interview",
"progress-summary": "Could not load progress saved on this device.",
};
const PROBLEM_FILTERS = new Set(["problem-topic", "problem-filter-summary"]);

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 exemption hides two real changes instead of handling them. Copying value onto every element in dom.js makes <option value=""> count as spoken. And applyDifficulties on load now writes "150 problems shown." into an aria-live region before anything has loaded, which a screen reader announces with no filter in use. Copy value only for form controls, and leave #problem-filter-summary empty while no topic is selected. Then this set can go.


test("the lobby page loads outside a browser and says what it was given", () => {
for (const [id, expected] of Object.entries(SIGNED_OUT)) {
Expand All @@ -62,7 +63,13 @@ test("no other panel of the lobby speaks before anything has loaded", () => {
const spoke = [];
for (const [, id] of markup.matchAll(/id="([^"]+)"/g)) {
const node = dom.node(id);
if (node && node.spoken().length && !(id in SIGNED_OUT)) spoke.push(id);
if (
node &&
node.spoken().length &&
!(id in SIGNED_OUT) &&
!PROBLEM_FILTERS.has(id)
)
spoke.push(id);
}
assert.deepEqual(
spoke,
Expand Down
81 changes: 81 additions & 0 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,87 @@ lobbyTest(
{ viewport: { width: 1280, height: 800 } },
);

lobbyTest(
"topic and difficulty filters narrow the problem cards together",
async (page) => {
await lobby(page);
await page.click("details.problem-picker summary");

await page.selectOption("#problem-topic", "Array");
const filtered = await page
.locator("[data-problem]:visible")
.evaluateAll((cards) =>
cards.map((card) => ({
difficulty: card.dataset.difficulty,
topics: card.dataset.topics.split("|"),
})),
);
assert.ok(filtered.length > 0, "the topic filter hid every problem");
assert.ok(
filtered.every(
(card) => card.difficulty === "Medium" && card.topics.includes("Array"),
),
"a visible card did not match both filters",
);

await page.click("#problem-filters-reset");
assert.ok(
(await page.locator("[data-problem]:visible").count()) > filtered.length,
"reset did not restore the other Medium problems",
);
},
);

lobbyTest(
"recent assessed reports produce a practice snapshot",
async (page) => {
reports = [
{
problemId: EASY[0],
createdAt: 30,
payload: { report: { decision: "NO_HIRE" } },
},
{
problemId: EASY[1],
createdAt: 20,
payload: { report: { decision: "HIRE" } },
},
{
problemId: MEDIUM[0],
createdAt: 10,
payload: { report: { decision: "NO_HIRE" } },
},
];
await lobby(page);

assert.equal(
await page.locator("#recent-performance-summary").textContent(),
"Last 3 assessed interviews: 1 passed, 2 missed (33%).",
);
assert.equal(await page.locator("#recent-performance").isHidden(), false);
},
);

lobbyTest(
"a failed report load hides the recent practice snapshot",
async (page) => {
failing = new Set(["/api/reports"]);
await page.goto(`${base}/`, { waitUntil: "domcontentloaded" });
await settles(
page,
() =>
document.querySelector("#progress-summary").textContent ===
"Could not load saved account progress.",
);

assert.equal(await page.locator("#recent-performance").isHidden(), true);
assert.equal(
await page.locator("#recent-performance-summary").textContent(),
"",
);
},
);

lobbyTest(
"a candidate who touches nothing gets the server's own default length",
async (page) => {
Expand Down
72 changes: 72 additions & 0 deletions tests/browser/practice-insights.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import {
availableTopics,
filterProblems,
recentPerformance,
} from "../../web/practice-insights.js";

const problems = [
{ id: "a", difficulty: "Easy", topics: ["Array", "Hash Table"] },
{ id: "b", difficulty: "Medium", topics: ["Array", "Sorting"] },
{ id: "c", difficulty: "Hard", topics: ["Graph"] },
];

test("topics are unique and sorted", () => {
assert.deepEqual(availableTopics(problems), [
"Array",
"Graph",
"Hash Table",
"Sorting",
]);
});

test("difficulty and topic filters combine", () => {
assert.deepEqual(
filterProblems(problems, {
difficulties: new Set(["Medium", "Hard"]),
topic: "Array",
}).map((problem) => problem.id),
["b"],
);
});

test("recent performance summarizes assessed reports newest first", () => {
const reports = [
{ problemId: "a", at: 10, report: { decision: "NO_HIRE" } },
{ problemId: "b", at: 30, report: { decision: "HIRE" } },
{ problemId: "a", at: 20, report: { decision: "NO_HIRE" } },
{ problemId: "c", at: 40, report: { decision: "HIRE" } },
];

assert.deepEqual(recentPerformance(problems, reports, 3), {
attempts: 3,
passes: 2,
misses: 1,
passRate: 67,
streak: 2,
latestDecision: "HIRE",
weakTopics: ["Hash Table"],
});
});

test("recent performance ignores unscored and undated entries", () => {
assert.equal(
recentPerformance(problems, [
{ problemId: "a", at: null, report: { decision: "NO_HIRE" } },
{ problemId: "b", at: 20, report: { decision: "PENDING" } },
]),
null,
);
});

test("topics with more misses than passes are highlighted", () => {
const snapshot = recentPerformance(problems, [
{ problemId: "a", at: 30, report: { decision: "NO_HIRE" } },
{ problemId: "a", at: 20, report: { decision: "NO_HIRE" } },
{ problemId: "b", at: 10, report: { decision: "HIRE" } },
]);

assert.deepEqual(snapshot.weakTopics, ["Hash Table", "Array"]);
});
Loading