Files
cs249r_book/book/tools/scripts/quizzes/playwright_verify.py
Vijay Janapa Reddi 3c95e3e67c refactor(quizzes): rename book/tools/scripts/genai/quiz_refresh to quizzes
The genai/ folder advertised an implementation detail (AI-driven) and
held only one tool. The siblings under book/tools/scripts/ are all
named by purpose (audit/, glossary/, mit_press/, publish/, ...) — this
brings the quiz pipeline into line with that convention.

Changes:
- git mv book/tools/scripts/genai/quiz_refresh -> book/tools/scripts/quizzes
- empty genai/ parent directory removed
- 4 path-string references rewritten (filters.yml comment, three
  internal docstrings/CLI examples in README/generate_quizzes/
  build_audit_context)
- 2 BUILD.md + DEVELOPMENT.md tree-listing entries updated to point at
  the new path
- README.md title and tagline updated (no longer "Quiz Refresh — runner";
  the "quiz-refresh pattern" label is replaced with "spec-plus-runner
  pattern" since the directory name now self-documents)
- generate_quizzes.py: meta["generated_by"] now writes
  "quizzes/generate_quizzes.py" (was "quiz-refresh/generate_quizzes.py");
  --skip-existing matcher loosened from "quiz-refresh" substring to
  "generate_quizzes" so it accepts both old and new descriptors
- 33 chapter quiz JSONs and 30 _audit/*_audit.json files: the
  generated_by metadata string updated to match new descriptor
- extract_anchors.py docstring: "quiz-refresh pipeline" -> "quizzes pipeline"

Verified post-rename:
- All scripts parse (ast.parse on all three .py files passes)
- validate_quiz_json.py works on sample chapters from each volume
- --skip-existing dry-run matches the new descriptor correctly
- git grep finds no remaining "genai/" or "quiz_refresh" references;
  the one remaining "quiz-refresh" reference is an intentional
  backwards-compat note in the --skip-existing comment block

The chapter quiz corpus and the spec at .claude/rules/quiz-generation.md
are unchanged in content — this is purely a directory + descriptor rename.
2026-04-25 08:09:47 -04:00

168 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Verify that a rendered chapter HTML has quizzes injected correctly.
Opens a rendered chapter (file path or URL) with Playwright and checks:
1. Every ``##`` section has a ``.callout-quiz-question`` div at its end,
OR a nearby subsection does — i.e., quizzes are injected.
2. The chapter has a ``## Self-Check Answers`` heading near the end
(id ``self-check-answers``).
3. Each ``#quiz-question-*`` has a matching ``#quiz-answer-*`` in the
Self-Check Answers section.
4. The "See Answers →" link in each question navigates to the correct
answer by anchor, and the "← Back to Questions" link returns.
5. No MCQ answer in the rendered body mentions ``Option X`` / ``Choice X``
/ ``Answer X`` / ``(A)`` letter references (anti-shuffle-bug).
Usage
-----
uv run --with playwright python3 verify_rendered.py <html_file_or_url> [...]
Exit 0 if every checked page passes; exit 1 on any error.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright, Page
LETTER_REF_PATTERNS = [
re.compile(r"\bOption [A-D]\b", re.IGNORECASE),
re.compile(r"\bChoice [A-D]\b", re.IGNORECASE),
re.compile(r"\bAnswer [A-D]\b", re.IGNORECASE),
re.compile(r"\([A-D]\)"),
]
def verify_page(page: Page, url: str) -> tuple[int, int]:
"""Return (errors, warnings) for this page."""
errors = 0
warnings = 0
def err(msg: str) -> None:
nonlocal errors
print(f" ERROR: {msg}")
errors += 1
def warn(msg: str) -> None:
nonlocal warnings
print(f" WARN: {msg}")
warnings += 1
page.goto(url)
page.wait_for_load_state("networkidle")
# (1) Question + answer callouts — Quarto renders them as
# ``<div class="callout callout-quiz-question" id="quiz-question-...">``.
# The answer div wraps a collapsed ``<details>`` that the reader
# expands to reveal the answer.
q_count = page.locator("div.callout-quiz-question").count()
a_count = page.locator("div.callout-quiz-answer").count()
# Chapter ``##`` headings use ``data-anchor-id`` (id is on the
# enclosing ``<section>``). Exclude the generated answer-key section.
h2_sections = page.locator(
"main h2[data-anchor-id^='sec-']:not([data-anchor-id='self-check-answers'])"
).count()
if q_count == 0:
err("no .callout-quiz-question elements — quizzes did not inject")
if q_count != a_count:
err(f"question/answer callout mismatch: {q_count} questions vs {a_count} answers")
# (2) Self-check-answers section — the id lives on the <section>.
answers_section = page.locator("section#self-check-answers")
if answers_section.count() == 0:
err("missing `<section id=self-check-answers>` — end-of-chapter answer key not present")
else:
nested_answers = page.locator("section#self-check-answers div.callout-quiz-answer").count()
if nested_answers == 0:
warn("self-check-answers section exists but contains no quiz-answer callouts")
elif nested_answers != a_count:
warn(f"only {nested_answers}/{a_count} answer callouts are inside the self-check-answers section")
# (3) Pair question ids with answer ids
q_ids = page.evaluate(
"""() => Array.from(document.querySelectorAll('div.callout-quiz-question')).map(e => e.id)"""
)
a_ids = set(
page.evaluate(
"""() => Array.from(document.querySelectorAll('div.callout-quiz-answer')).map(e => e.id)"""
)
)
for qid in q_ids:
if not qid.startswith("quiz-question-"):
err(f"malformed question id: {qid!r}")
continue
expected_aid = "quiz-answer-" + qid[len("quiz-question-"):]
if expected_aid not in a_ids:
err(f"question {qid} has no matching answer {expected_aid}")
# (4) Cross-navigation links
see_links = page.locator("a.question-label").count()
back_links = page.locator("a.answer-label").count()
if see_links != q_count:
warn(f"expected {q_count} 'See Answers' links, found {see_links}")
if back_links != a_count:
warn(f"expected {a_count} 'Back to Questions' links, found {back_links}")
# (5) Anti-shuffle-bug: no letter-references in rendered answer text
if a_count > 0:
answer_texts = page.evaluate(
"""() => Array.from(document.querySelectorAll('div.callout-quiz-answer'))
.map(e => e.innerText)"""
)
for i, text in enumerate(answer_texts):
for pat in LETTER_REF_PATTERNS:
m = pat.search(text)
if m:
warn(
f"answer block {i} contains a letter-reference '{m.group(0)}'"
"spec §5/§10 requires content-based distractor references"
)
break
print(
f" summary: ## sections={h2_sections} Q={q_count} A={a_count} "
f"self-check-answers={'yes' if answers_section.count() > 0 else 'no'}"
)
return errors, warnings
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: verify_rendered.py <html_file_or_url> [...]", file=sys.stderr)
return 2
urls = []
for arg in argv[1:]:
if arg.startswith(("http://", "https://", "file://")):
urls.append(arg)
else:
p = Path(arg).resolve()
if not p.is_file():
print(f"error: {arg} is not a file or URL", file=sys.stderr)
return 2
urls.append(p.as_uri())
total_errors = 0
total_warnings = 0
with sync_playwright() as pw:
browser = pw.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
for url in urls:
print(f"\n=== {url} ===")
e, w = verify_page(page, url)
total_errors += e
total_warnings += w
browser.close()
print(f"\n=== TOTAL: errors={total_errors}, warnings={total_warnings} ===")
return 1 if total_errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))