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
45 changes: 32 additions & 13 deletions scripts/gen-problem-cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
# the only one that reads them. Run it first.
PAGE_MAP = ROOT / "web" / "problem-pages.json"
INDEX = ROOT / "web" / "index.html"
TOPIC_MAP = ROOT / "web" / "problem-topics.json"
# Matched without their indentation, which the surrounding markup owns and has
# already changed once: wrapping the grid in a `<details>` moved it two columns
# right, and the six-space literals these used to be went on matching only
Expand All @@ -37,18 +38,17 @@ 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
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.
Keyed by page name, with the scenario title the recommendation line reads.
A `coin-change` key, published title or "Dynamic Programming" tag tells the
candidate what they are about to be asked, so none ships in the initial
page. The published title and topic map are fetched only after the candidate
opts into the corresponding picker control.
"""
return "\n".join(
[
Expand All @@ -61,8 +61,7 @@ def card(problem: dict[str, object], page: str, title: str) -> str:
)


def generated(problems: list) -> str:
pages = read_json(PAGE_MAP)
def generated(problems: list, pages: dict) -> str:
cards = "\n".join(
card(problem, pages[problem["id"]]["page"], pages[problem["id"]]["title"])
for problem in problems
Expand All @@ -77,9 +76,18 @@ def generated(problems: list) -> str:
)


def generated_topics(problems: list, pages: dict) -> str:
"""The opt-in topic filter's page-to-topic lookup."""
mapping = {
pages[problem["id"]]["page"]: problem.get("topics", [])
for problem in problems
}
return json.dumps(mapping, ensure_ascii=False, indent=2) + "\n"


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 All @@ -101,20 +109,31 @@ def main() -> int:
return 1

problems = read_json(SOURCE)
block = textwrap.indent(generated(problems), indent)
pages = read_json(PAGE_MAP)
block = textwrap.indent(generated(problems, pages), indent)
updated = html_text[: opening.start()] + block + html_text[end.end() :]
topics = generated_topics(problems, pages)
current_topics = TOPIC_MAP.read_text(encoding="utf-8") if TOPIC_MAP.exists() else ""
if args.check:
stale = []
if updated != html_text:
stale.append("web/index.html problem cards")
if topics != current_topics:
stale.append("web/problem-topics.json")
if stale:
print(
"web/index.html problem cards are stale; run: python3 scripts/gen-problem-cards.py",
f"{', '.join(stale)} are stale; run: python3 scripts/gen-problem-cards.py",
file=sys.stderr,
)
return 1
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")
if topics != current_topics:
TOPIC_MAP.write_text(topics, encoding="utf-8")
print(f"updated {len(problems)} problem topics in web/problem-topics.json")
return 0


Expand Down
16 changes: 15 additions & 1 deletion tests/browser/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ register("./dom-hooks.js", pathToFileURL(`${import.meta.dirname}/`));
/// properties that put a string on the page without going through `textContent`.
const SPOKEN_ATTRIBUTES = ["title", "ariaLabel", "alt", "placeholder", "value"];
const MARKUP = ["innerHTML", "innerText", "outerHTML"];
const FORM_CONTROLS = new Set(["button", "input", "select", "textarea"]);

class Element {
#text = "";
Expand Down Expand Up @@ -334,7 +335,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 (FORM_CONTROLS.has(element.tag) && "value" in element.attributes)
created.value = element.attributes.value;
nodes.set(element, created);
}
return nodes.get(element);
};
globalThis.document = {
Expand Down
1 change: 0 additions & 1 deletion tests/browser/lobby-render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ const SIGNED_OUT = {
start: "Start interview",
"progress-summary": "Could not load progress saved on this device.",
};

test("the lobby page loads outside a browser and says what it was given", () => {
for (const [id, expected] of Object.entries(SIGNED_OUT)) {
assert.deepEqual(dom.node(id).spoken(), [expected], `#${id}`);
Expand Down
206 changes: 206 additions & 0 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ const markupDuration = () =>
/// The page name a card ships in the URL, from the map the generator writes.
const pageOf = (problemId) =>
JSON.parse(read("web/problem-pages.json"))[problemId].page;
const TOPICS_BY_PAGE = JSON.parse(read("web/problem-topics.json"));

const hired = (problemId) => ({
problemId,
Expand Down Expand Up @@ -331,6 +332,13 @@ const restore = (page) =>
),
);

async function openTopicFilter(page) {
await page.click("details.problem-picker summary");
await page.waitForFunction(
() => !document.querySelector("#problem-topic").disabled,
);
}

/// What the difficulty filter did to one card.
const cardInfo = (page, id) =>
page.evaluate((problem) => {
Expand Down Expand Up @@ -362,6 +370,204 @@ lobbyTest(
{ viewport: { width: 1280, height: 800 } },
);

lobbyTest(
"topic and difficulty filters narrow the problem cards together",
async (page) => {
const requests = [];
page.on("request", (request) =>
requests.push(new URL(request.url()).pathname),
);
await lobby(page);
assert.equal(
requests.includes("/problem-topics.json"),
false,
"the initial lobby disclosed the topic map",
);
assert.equal(
await page.locator("[data-topics]").count(),
0,
"a problem card disclosed its topics in the HTML",
);
assert.equal(
await page.locator("#problem-filter-summary").textContent(),
"",
"the untouched live region announced a count",
);
await openTopicFilter(page);
assert.equal(requests.includes("/problem-topics.json"), true);

await page.selectOption("#problem-topic", "Array");
const filtered = await page
.locator("[data-problem]:visible")
.evaluateAll((cards) =>
cards.map((card) => ({
id: card.dataset.problem,
difficulty: card.dataset.difficulty,
})),
);
assert.ok(filtered.length > 0, "the topic filter hid every problem");
assert.ok(
filtered.every(
(card) =>
card.difficulty === "Medium" &&
TOPICS_BY_PAGE[card.id].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",
);
assert.equal(
await page.locator("#problem-filter-summary").textContent(),
"",
);
},
);

lobbyTest("a topic choice does not survive a lobby restore", async (page) => {
await lobby(page);
await openTopicFilter(page);
await page.selectOption("#problem-topic", "Array");
assert.match(
await page.locator("#problem-filter-summary").textContent(),
/tagged Array/,
);

await restore(page);
await awaitReady(page);
assert.equal(await page.locator("#problem-topic").inputValue(), "");
assert.equal(await page.locator("#problem-filter-summary").textContent(), "");
});

lobbyTest(
"a hidden manual pick does not survive the combined filters",
async (page) => {
const hardArray = Object.entries(TOPICS_BY_PAGE).find(
([pageName, topics]) =>
topics.includes("Array") &&
read("web/index.html").includes(
`data-problem="${pageName}" data-difficulty="Hard"`,
),
)[0];
await lobby(page);
await openTopicFilter(page);
await page.evaluate((id) => {
const card = document.querySelector(`[data-problem="${id}"]`);
card.hidden = false;
card.click();
}, hardArray);

await page.selectOption("#problem-topic", "Array");
const state = await snapshot(page);
assert.notEqual(state.card, hardArray);
assert.equal((await cardInfo(page, hardArray)).hidden, true);
assert.equal((await cardInfo(page, state.card)).hidden, false);
},
);

lobbyTest(
"a due review outside the selected topic remains the first priority",
async (page) => {
const due = pageOf("valid-parentheses");
assert.equal(TOPICS_BY_PAGE[due].includes("Array"), false);
reports = [savedAttempt(due)];
await lobby(page);
await openTopicFilter(page);
await page.selectOption("#problem-topic", "Array");

const state = await snapshot(page);
assert.equal(state.card, due);
assert.match(state.note, /Review due after/);
assert.equal((await cardInfo(page, due)).hidden, false);
},
);

lobbyTest(
"a completed topic and level are described without overstating progress",
async (page) => {
const markup = read("web/index.html");
const mediumArray = Object.entries(TOPICS_BY_PAGE)
.filter(
([pageName, topics]) =>
topics.includes("Array") &&
markup.includes(
`data-problem="${pageName}" data-difficulty="Medium"`,
),
)
.map(([pageName]) => pageName);
reports = mediumArray.map(hired);
await lobby(page);
await setLevel(page, "Medium", true);
await setLevel(page, "Easy", false);
await setLevel(page, "Hard", false);
await openTopicFilter(page);
await page.selectOption("#problem-topic", "Array");

assert.match(
(await snapshot(page)).note,
/You have passed every problem matching this topic and level\./,
);
},
);

lobbyTest(
"recent assessed reports produce a practice snapshot",
async (page) => {
reports = [
{
problemId: HARD[0],
createdAt: 40,
payload: { report: { incomplete: true, decision: "HIRE" } },
},
{
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
Loading
Loading