=3:
- widths=[14*mm]+[(168-14)/ (ncols-1) *mm]*(ncols-1)
- t._argW=widths
- flows.append(t); flows.append(Spacer(1,4))
- continue
- # option lines start with full-width space or " A." style
- if re.match(r"^[ \s]*[A-E][..、]", s):
- flows.append(Paragraph(inline(s), OPT)); i+=1; continue
- flows.append(Paragraph(inline(s), BODY)); i+=1
- return flows
-
-def build(md_path, pdf_path):
- md=open(md_path,encoding="utf-8").read()
- doc=SimpleDocTemplate(pdf_path, pagesize=A4,
- leftMargin=20*mm,rightMargin=20*mm,topMargin=18*mm,bottomMargin=18*mm,
- title=pdf_path.split("/")[-1])
- doc.build(parse(md))
- print("wrote", pdf_path)
-
-if __name__=="__main__":
- base="/Users/td_xu/courses/毛中特/output/"
- build(base+"毛中特期末模拟卷.md", base+"毛中特期末模拟卷.pdf")
- build(base+"毛中特期末模拟卷-答案解析.md", base+"毛中特期末模拟卷-答案解析.pdf")
diff --git a/scripts/misconception.py b/scripts/misconception.py
index ccbab9f..bef1961 100644
--- a/scripts/misconception.py
+++ b/scripts/misconception.py
@@ -7,7 +7,8 @@
import typer
from studylib.cli_common import guard, resolve_root
-from studylib.ioutils import read_jsonl
+from studylib.display import kc_label
+from studylib.ioutils import read_json, read_jsonl
app = typer.Typer(add_completion=False)
@@ -21,9 +22,11 @@ def list_cmd(course: Path = typer.Option(None, "--course")):
if not active:
typer.echo("没有活跃错因")
return
+ kcs = read_json(root / ".study" / "kc.json")
for m in active:
+ kc_disp = " / ".join(kc_label(k, kcs) for k in m["kc_ids"])
typer.echo(f"{m['error_id']} [{m['repair_status']}] {m['error_type']} "
- f"kc={','.join(m['kc_ids'])} ×{m.get('recurrence_count', 1)}")
+ f"kc={kc_disp} ×{m.get('recurrence_count', 1)}")
typer.echo(f" 错误假设:{m.get('wrong_assumption', '')}")
diff --git a/scripts/render_paper.py b/scripts/render_paper.py
new file mode 100644
index 0000000..ace9c4d
--- /dev/null
+++ b/scripts/render_paper.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import typer
+
+from studylib.cli_common import guard
+from studylib.ioutils import read_json
+from studylib.render_paper import manifest_to_markdown, markdown_to_pdf
+
+app = typer.Typer(add_completion=False)
+
+
+@app.command()
+@guard
+def main(
+ manifest: Path = typer.Option(..., "--manifest"),
+ variant: str = typer.Option("both", "--variant"),
+ out_dir: Path = typer.Option(None, "--out-dir"),
+ fonts_dir: Path = typer.Option(None, "--fonts-dir"),
+):
+ if variant not in ("questions", "answers", "both"):
+ typer.echo("错误:--variant 必须是 questions/answers/both", err=True)
+ raise typer.Exit(code=1)
+ m = read_json(manifest)
+ if not m:
+ typer.echo(f"错误:manifest 不存在或为空:{manifest}", err=True)
+ raise typer.Exit(code=1)
+ out_dir = out_dir or manifest.parent
+ out_dir.mkdir(parents=True, exist_ok=True)
+ base = m["meta"].get("course_name", "quiz")
+ variants = ["questions", "answers"] if variant == "both" else [variant]
+ for v in variants:
+ md = manifest_to_markdown(m, v)
+ tag = "题目" if v == "questions" else "答案解析"
+ pdf = out_dir / f"{base}-{tag}.pdf"
+ markdown_to_pdf(md, pdf, fonts_dir=fonts_dir)
+ typer.echo(f"已生成:{pdf}")
+
+
+if __name__ == "__main__":
+ app()
diff --git a/scripts/render_quiz_html.py b/scripts/render_quiz_html.py
new file mode 100755
index 0000000..fc95b54
--- /dev/null
+++ b/scripts/render_quiz_html.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import typer
+
+from studylib.cli_common import guard
+from studylib.ioutils import atomic_write_text, read_json
+from studylib.render_html import render_quiz_html
+
+app = typer.Typer(add_completion=False)
+
+
+@app.command()
+@guard
+def main(
+ manifest: Path = typer.Option(..., "--manifest"),
+ out: Path = typer.Option(None, "--out"),
+ reveal_default: str = typer.Option("on", "--reveal-default"),
+):
+ m = read_json(manifest)
+ if not m:
+ typer.echo(f"错误:manifest 不存在或为空:{manifest}", err=True)
+ raise typer.Exit(code=1)
+ html = render_quiz_html(m, reveal_default == "on")
+ out = out or manifest.with_suffix(".html")
+ atomic_write_text(out, html)
+ typer.echo(f"已生成交互测验页:{out}")
+
+
+if __name__ == "__main__":
+ app()
diff --git a/scripts/studylib/cli_common.py b/scripts/studylib/cli_common.py
index 432b83f..a929a03 100644
--- a/scripts/studylib/cli_common.py
+++ b/scripts/studylib/cli_common.py
@@ -29,7 +29,7 @@ def echo_next(state: dict) -> None:
if nbs["action"] == "rest":
typer.echo(f"下一步建议:rest —— {nbs['reasons'][0]}")
return
- target = nbs.get("kc_name") or "到期复习"
+ target = nbs.get("kc_label") or nbs.get("kc_name") or "到期复习"
typer.echo(f"下一步建议:{nbs['action']}「{target}」(约 {nbs['estimated_minutes']} 分钟)")
typer.echo("原因:")
for r in nbs["reasons"]:
diff --git a/scripts/studylib/dashboard.py b/scripts/studylib/dashboard.py
index 06f0760..d0f0810 100644
--- a/scripts/studylib/dashboard.py
+++ b/scripts/studylib/dashboard.py
@@ -4,17 +4,20 @@
from jinja2 import Template
+from .display import kc_label
+
TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "dashboard.md.j2"
def build_risks(kc_states: dict, miscs: dict, due: list) -> list[str]:
risks: list[str] = []
for kc in kc_states.values():
+ label = kc_label(kc.get("kc_id", ""), kc_states)
gap = kc["calibration"].get("gap")
if kc["teaching_state"] == "weak" and gap is not None and gap >= 0.3:
- risks.append(f"「{kc['name']}」:高置信度盲区")
+ risks.append(f"{label}:高置信度盲区")
elif kc["teaching_state"] == "blocked":
- risks.append(f"「{kc['name']}」:前置未稳定")
+ risks.append(f"{label}:前置未稳定")
for m in miscs.values():
if m["repair_status"] != "resolved" and m.get("recurrence_count", 1) >= 2:
risks.append(f"错因复发:{m['error_type']}(×{m['recurrence_count']})")
diff --git a/scripts/studylib/display.py b/scripts/studylib/display.py
new file mode 100644
index 0000000..491c861
--- /dev/null
+++ b/scripts/studylib/display.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+
+def kc_label(kc_id: str, kcs: dict[str, dict] | None = None) -> str:
+ """User-facing KC label: 'kc_id(中文名)'. Falls back to bare kc_id."""
+ kc = (kcs or {}).get(kc_id) or {}
+ name = kc.get("name")
+ if not name or name == kc_id:
+ return kc_id
+ return f"{kc_id}({name})"
diff --git a/scripts/studylib/drill.py b/scripts/studylib/drill.py
new file mode 100644
index 0000000..1515fdc
--- /dev/null
+++ b/scripts/studylib/drill.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import random
+
+from .nextstep import WEAKNESS_SCORE
+
+
+def _weighted_sample_no_replace(
+ rng: random.Random, items: list[str], weights: list[float], k: int
+) -> list[str]:
+ if k >= len(items):
+ return list(items)
+ # Efraimidis–Spirakis: key = u**(1/w); take the k largest keys.
+ pairs = []
+ for it, w in zip(items, weights):
+ ww = w if w > 0 else 1e-9
+ pairs.append((rng.random() ** (1.0 / ww), it))
+ pairs.sort(key=lambda p: p[0], reverse=True)
+ return [it for _, it in pairs[:k]]
+
+
+def select_kcs(kc_states: dict[str, dict], mode: str, count: int, seed: int = 0) -> list[str]:
+ if mode not in ("syllabus", "diagnostic"):
+ raise ValueError(f"unknown mode: {mode}")
+ ids = list(kc_states)
+ if not ids or count <= 0:
+ return []
+ rng = random.Random(seed)
+ if mode == "syllabus":
+ weights = [kc_states[i].get("exam_weight", 0.5) for i in ids]
+ else: # diagnostic (adaptive: prefers weak KCs when any learning record exists)
+ has_record = any(kc_states[i].get("teaching_state") != "unseen" for i in ids)
+ if has_record:
+ weights = [WEAKNESS_SCORE.get(kc_states[i].get("teaching_state"), 0.0) for i in ids]
+ else:
+ weights = [kc_states[i].get("exam_weight", 0.5) for i in ids]
+ return _weighted_sample_no_replace(rng, ids, weights, min(count, len(ids)))
+
+
+def gather_questions(
+ questions: dict, kc_ids: list[str], per_kc: int = 2, total: int | None = None
+) -> tuple[list[dict], dict[str, int]]:
+ total = total if total is not None else per_kc * max(1, len(kc_ids))
+ by_kc: dict[str, list[dict]] = {k: [] for k in kc_ids}
+ for q in questions.values():
+ for k in q.get("kc_ids", []):
+ if k in by_kc and len(by_kc[k]) < per_kc:
+ by_kc[k].append(q)
+ picked: list[dict] = []
+ seen: set = set()
+ i = 0
+ while len(picked) < total:
+ progressed = False
+ for k in kc_ids:
+ if i < len(by_kc[k]):
+ progressed = True
+ q = by_kc[k][i]
+ qid = q.get("question_id")
+ if qid not in seen:
+ seen.add(qid)
+ picked.append(q)
+ if len(picked) >= total:
+ break
+ if not progressed:
+ break
+ i += 1
+ shortfall = {k: per_kc - len(by_kc[k]) for k in kc_ids if len(by_kc[k]) < per_kc}
+ return picked, shortfall
diff --git a/scripts/studylib/ioutils.py b/scripts/studylib/ioutils.py
index 85cfe26..7a49e86 100644
--- a/scripts/studylib/ioutils.py
+++ b/scripts/studylib/ioutils.py
@@ -55,6 +55,13 @@ def read_jsonl(path: Path) -> list[dict]:
return rows
+def read_json(path: Path) -> dict:
+ path = Path(path)
+ if not path.exists():
+ return {}
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
class _CourseLock:
"""Non-reentrant course-level lock; raises StateLockTimeout on timeout."""
diff --git a/scripts/studylib/manifest.py b/scripts/studylib/manifest.py
new file mode 100644
index 0000000..fb68f21
--- /dev/null
+++ b/scripts/studylib/manifest.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from .display import kc_label
+from .ioutils import now_iso
+from .schemas import SCHEMA_VERSION
+
+
+def _question_view(q: dict, kcs: dict | None) -> dict:
+ kc_ids = list(q.get("kc_ids", []))
+ return {
+ "question_id": q["question_id"],
+ "kc_ids": kc_ids,
+ "kc_labels": [kc_label(k, kcs) for k in kc_ids],
+ "stem": q.get("stem", ""),
+ "answer": q.get("answer", ""),
+ "solution": q.get("solution", ""),
+ "difficulty": q.get("difficulty", 0.5),
+ "transfer_level": q.get("transfer_level", "T0"),
+ }
+
+
+def build_manifest(course: dict, mode: str, count: int, questions: list[dict],
+ kcs: dict | None = None) -> dict:
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "meta": {
+ "course_id": course["id"],
+ "course_name": course["name"],
+ "mode": mode,
+ "count": count,
+ "generated_at": now_iso(),
+ },
+ "questions": [_question_view(q, kcs) for q in questions],
+ }
diff --git a/scripts/studylib/nextstep.py b/scripts/studylib/nextstep.py
index 74df560..8a49706 100644
--- a/scripts/studylib/nextstep.py
+++ b/scripts/studylib/nextstep.py
@@ -2,6 +2,8 @@
from datetime import date
+from .display import kc_label
+
DEFAULT_WEIGHTS = {
"exam_weight": 1.0, "urgency": 1.0, "weakness": 1.5, "prereq_centrality": 0.8,
"forgetting_risk": 1.0, "transfer_gap": 0.8, "blind_spot": 1.2, "expected_time": 0.5,
@@ -110,7 +112,9 @@ def compute_next_best_step(
reasons.append(f"距考试 {days} 天")
candidates.append({
- "action": action, "kc_id": kc_id, "kc_name": kc.get("name", kc_id),
+ "action": action, "kc_id": kc_id,
+ "kc_name": kc.get("name", kc_id),
+ "kc_label": kc_label(kc_id, kc_states),
"estimated_minutes": minutes, "priority_score": round(score, 4),
"reasons": reasons,
})
diff --git a/scripts/studylib/render_html.py b/scripts/studylib/render_html.py
new file mode 100644
index 0000000..ec6ed2a
--- /dev/null
+++ b/scripts/studylib/render_html.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import re
+import unicodedata
+from pathlib import Path
+
+from jinja2 import Environment
+
+TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "quiz.html.j2"
+
+_OPT_RE = re.compile(r"^\s*([A-Ha-hA-Ha-h])[..、)]\s*(.*)$")
+
+
+def parse_options(stem: str) -> tuple[str, list[tuple[str, str]]]:
+ lines = stem.splitlines()
+ body: list[str] = []
+ opts: list[list[str]] = [] # each: [letter, text]
+ in_opts = False
+ for ln in lines:
+ m = _OPT_RE.match(ln)
+ if m:
+ in_opts = True
+ letter = unicodedata.normalize("NFKC", m.group(1)).upper()
+ opts.append([letter, m.group(2).strip()])
+ elif not in_opts:
+ body.append(ln)
+ else:
+ opts[-1][1] = (opts[-1][1] + " " + ln.strip()).strip()
+ return "\n".join(body).strip(), [(o[0], o[1]) for o in opts]
+
+
+def _question_for_render(q: dict) -> dict:
+ body, opts = parse_options(q.get("stem", ""))
+ ans = q.get("answer", "") or ""
+ letters = sorted({unicodedata.normalize("NFKC", ch).upper() for ch in ans if ch.strip()})
+ return {
+ "stem": body,
+ "options": [{"letter": l, "text": t} for l, t in opts],
+ "answer": "".join(letters),
+ "answer_letters": letters,
+ "multi": len(letters) > 1,
+ "solution": q.get("solution", ""),
+ "kc_labels": q.get("kc_labels") or q.get("kc_ids", []),
+ }
+
+
+def render_quiz_html(manifest: dict, reveal_default: bool = True) -> str:
+ tpl = Environment(autoescape=True).from_string(TEMPLATE_PATH.read_text(encoding="utf-8"))
+ view = {
+ "meta": manifest["meta"],
+ "questions": [_question_for_render(q) for q in manifest["questions"]],
+ }
+ return tpl.render(m=view, reveal_default=("on" if reveal_default else "off"))
diff --git a/scripts/studylib/render_paper.py b/scripts/studylib/render_paper.py
new file mode 100644
index 0000000..c9a8d19
--- /dev/null
+++ b/scripts/studylib/render_paper.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import html as _html
+from pathlib import Path
+
+from reportlab.lib import colors
+from reportlab.lib.enums import TA_LEFT
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
+from reportlab.lib.units import mm
+from reportlab.pdfbase import pdfmetrics
+from reportlab.pdfbase.cidfonts import UnicodeCIDFont
+from reportlab.pdfbase.pdfmetrics import registerFontFamily
+from reportlab.pdfbase.ttfonts import TTFont
+from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate
+
+BODY_FONT = "Body"
+BOLD_FONT = "HeitiM"
+_REGISTERED_KEY: str | None = None # guards repeated registration in one process
+
+
+def _register_fonts(fonts_dir: Path | None) -> None:
+ """Register CJK fonts. Prefer Heiti .ttc from fonts_dir; else fall back to
+ reportlab's built-in CID font STSong-Light (no font files needed). Sets the
+ module-level BODY_FONT/BOLD_FONT so styles resolve correctly either way."""
+ global BODY_FONT, BOLD_FONT, _REGISTERED_KEY
+ fonts_dir = Path(fonts_dir) if fonts_dir else None
+ use_heiti = bool(fonts_dir and (fonts_dir / "STHeitiLight.ttc").exists()
+ and (fonts_dir / "STHeitiMedium.ttc").exists())
+ key = f"heiti:{fonts_dir}" if use_heiti else "cid"
+ if _REGISTERED_KEY == key:
+ return # already registered with this config in this process
+ if use_heiti:
+ pdfmetrics.registerFont(TTFont("Body", str(fonts_dir / "STHeitiLight.ttc"), subfontIndex=0))
+ pdfmetrics.registerFont(TTFont("HeitiM", str(fonts_dir / "STHeitiMedium.ttc"), subfontIndex=0))
+ BODY_FONT, BOLD_FONT = "Body", "HeitiM"
+ else:
+ # UnicodeCIDFont registers under its face name; reuse that name for both.
+ pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
+ BODY_FONT = BOLD_FONT = "STSong-Light"
+ registerFontFamily(BODY_FONT, normal=BODY_FONT, bold=BOLD_FONT,
+ italic=BODY_FONT, boldItalic=BOLD_FONT)
+ _REGISTERED_KEY = key
+
+
+def _styles():
+ ss = getSampleStyleSheet()
+ return {
+ "h1": ParagraphStyle("h1", parent=ss["Title"], fontName=BOLD_FONT, fontSize=16,
+ leading=22, spaceAfter=6, textColor=colors.HexColor("#1a1a1a")),
+ "body": ParagraphStyle("body", fontName=BODY_FONT, fontSize=10.5, leading=16.5,
+ alignment=TA_LEFT, spaceAfter=3),
+ "opt": ParagraphStyle("opt", fontName=BODY_FONT, fontSize=10.5, leading=15,
+ leftIndent=14, spaceAfter=1),
+ "quote": ParagraphStyle("quote", fontName=BODY_FONT, fontSize=10, leading=15,
+ leftIndent=16, textColor=colors.HexColor("#444444"), spaceAfter=3),
+ }
+
+
+def _inline(t: str) -> str:
+ t = t.replace("\\_", "_")
+ t = _html.escape(t)
+ parts = t.split("**")
+ if len(parts) > 1:
+ out = []
+ for i, p in enumerate(parts):
+ out += ["", p, ""] if i % 2 else [p]
+ t = "".join(out)
+ return t
+
+
+def manifest_to_markdown(manifest: dict, variant: str) -> str:
+ if variant not in ("questions", "answers"):
+ raise ValueError(f"unknown variant: {variant}")
+ meta = manifest["meta"]
+ lines = [f"# {meta['course_name']} · 模拟卷",
+ f"> {meta['mode']} 模式 · {len(manifest['questions'])} 题 · 生成于 {meta['generated_at']}", ""]
+ for i, q in enumerate(manifest["questions"], 1):
+ kcs = " · ".join(q.get("kc_labels") or q.get("kc_ids", []))
+ lines.append(f"### 第 {i} 题 {kcs}")
+ lines.append(q.get("stem", ""))
+ if variant == "answers":
+ lines += ["", f"**答案:{q.get('answer', '')}**", f"> 解析:{q.get('solution', '')}"]
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _parse(md: str, st: dict) -> list:
+ import re
+ flows, lines, i, n = [], md.split("\n"), 0, len(md.split("\n"))
+ while i < n:
+ line = lines[i].rstrip("\n")
+ s = line.strip()
+ if s == "":
+ i += 1
+ continue
+ if s == "---":
+ flows.append(HRFlowable(width="100%", thickness=0.6,
+ color=colors.HexColor("#bbbbbb"), spaceBefore=4, spaceAfter=4))
+ i += 1
+ continue
+ if s.startswith("# "):
+ flows.append(Paragraph(_inline(s[2:].strip()), st["h1"])); i += 1; continue
+ if s.startswith("### "):
+ flows.append(Paragraph(_inline(s[4:].strip()),
+ ParagraphStyle("h3", parent=st["body"], fontName=BOLD_FONT,
+ textColor=colors.HexColor("#0b5394"), spaceBefore=6)))
+ i += 1; continue
+ if s.startswith("> "):
+ blk = []
+ while i < n and lines[i].strip().startswith(">"):
+ blk.append(lines[i].strip()[1:].strip()); i += 1
+ flows.append(Paragraph("
".join(_inline(x) for x in blk), st["quote"])); continue
+ if re.match(r"^[ \s]*[A-H][..、]", s):
+ flows.append(Paragraph(_inline(s), st["opt"])); i += 1; continue
+ flows.append(Paragraph(_inline(s), st["body"])); i += 1
+ return flows
+
+
+def markdown_to_pdf(md: str, pdf_path: Path, fonts_dir: Path | None = None) -> Path:
+ _register_fonts(fonts_dir)
+ doc = SimpleDocTemplate(str(pdf_path), pagesize=A4,
+ leftMargin=20 * mm, rightMargin=20 * mm,
+ topMargin=18 * mm, bottomMargin=18 * mm,
+ title=Path(pdf_path).name)
+ doc.build(_parse(md, _styles()))
+ return Path(pdf_path)
diff --git a/templates/dashboard.md.j2 b/templates/dashboard.md.j2
index 4481eec..805aa57 100644
--- a/templates/dashboard.md.j2
+++ b/templates/dashboard.md.j2
@@ -3,7 +3,7 @@
{% if s.next_best_step.action == "rest" -%}
暂无紧急任务。可以休息,或自由推进新章节。
{%- else -%}
-**{{ s.next_best_step.action }}**:{{ s.next_best_step.kc_name or "到期复习" }}
+**{{ s.next_best_step.action }}**:{{ s.next_best_step.kc_label or s.next_best_step.kc_name or "到期复习" }}
预计:{{ s.next_best_step.estimated_minutes }} 分钟
为什么:
diff --git a/templates/quiz.html.j2 b/templates/quiz.html.j2
new file mode 100644
index 0000000..fac97a1
--- /dev/null
+++ b/templates/quiz.html.j2
@@ -0,0 +1,121 @@
+
+
+
+
+{{ m.meta.course_name }} · 练习
+
+
+
+{{ m.meta.course_name }} · 练习卷
+{{ m.meta.mode }} 模式 · {{ m.questions | count }} 题 · 生成于 {{ m.meta.generated_at }}
+
+
+
+
+
+
+
+
+
+
+
+{% for q in m.questions %}
+{% set qnum = loop.index %}
+
+ {% for l in q.kc_labels %}{{ l }}{% if not loop.last %} · {% endif %}{% endfor %}
+ {{ qnum }}. {{ q.stem }}
+
+ {% for o in q.options %}
+
+ {% endfor %}
+
+
+
答案:{{ q.answer }}
+
{{ q.solution }}
+
+
+{% endfor %}
+
+
+
+
diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py
index f9bc766..3066431 100644
--- a/tests/test_cli_smoke.py
+++ b/tests/test_cli_smoke.py
@@ -67,3 +67,77 @@ def test_cli_friendly_error_outside_course(tmp_path):
r = run([SCRIPTS / "next_step.py"], tmp_path, tmp_path / "home")
assert r.returncode == 1
assert "course.yaml" in r.stderr + r.stdout
+
+
+def test_evidence_and_misconception_show_labels(tmp_path):
+ home = tmp_path / "home"
+ course_dir = tmp_path / "模电"
+ r = run([SCRIPTS / "init_course.py", str(course_dir), "--course-id", "analog",
+ "--name", "模拟电子技术"], tmp_path, home)
+ assert r.returncode == 0, r.stderr
+ r = run([SCRIPTS / "event.py", "kc-add", "--kc-id", "feedback_topology",
+ "--name", "反馈组态判断", "--exam-weight", "0.9"], course_dir, home)
+ assert r.returncode == 0, r.stderr
+ cand = course_dir / "q.json"
+ cand.write_text(json.dumps({
+ "question_id": "q1", "kc_ids": ["feedback_topology"], "source_type": "past_exam",
+ "transfer_level": "T0", "stem": "判断反馈组态", "answer": "A",
+ }, ensure_ascii=False), encoding="utf-8")
+ assert run([SCRIPTS / "validate_question.py", str(cand)], course_dir, home).returncode == 0
+ assert run([SCRIPTS / "event.py", "attempt", "--question-id", "q1",
+ "--wrong", "--confidence", "0.9"], course_dir, home).returncode == 0
+ assert run([SCRIPTS / "event.py", "misconception", "--error-id", "err_001",
+ "--kc", "feedback_topology", "--question", "q1",
+ "--wrong-assumption", "x", "--missing-premise", "y",
+ "--error-type", "concept_misconception"], course_dir, home).returncode == 0
+
+ r = run([SCRIPTS / "evidence.py", "--kc", "feedback_topology"], course_dir, home)
+ assert r.returncode == 0, r.stderr
+ assert "feedback_topology(反馈组态判断)" in r.stdout
+
+ r = run([SCRIPTS / "misconception.py"], course_dir, home)
+ assert r.returncode == 0, r.stderr
+ assert "feedback_topology(反馈组态判断)" in r.stdout
+
+
+def test_render_quiz_html_cli(tmp_path):
+ home = tmp_path / "home"
+ course_dir = tmp_path / "模电"
+ assert run([SCRIPTS / "init_course.py", str(course_dir), "--course-id", "analog",
+ "--name", "模拟电子技术"], tmp_path, home).returncode == 0
+ manifest = course_dir / "m.json"
+ manifest.write_text(json.dumps({
+ "meta": {"course_id": "analog", "course_name": "模拟电子技术", "mode": "syllabus",
+ "count": 1, "generated_at": "2026-07-20T00:00:00+08:00"},
+ "questions": [{"question_id": "q1", "kc_labels": ["k(名)"],
+ "stem": "Q\nA.x\nB.y", "answer": "A", "solution": "s"}],
+ }, ensure_ascii=False), encoding="utf-8")
+ out = course_dir / "quiz.html"
+ r = run([SCRIPTS / "render_quiz_html.py", "--manifest", str(manifest),
+ "--out", str(out), "--reveal-default", "on"], course_dir, home)
+ assert r.returncode == 0, r.stderr
+ text = out.read_text(encoding="utf-8")
+ assert "id=\"revealToggle\"" in text and "模拟电子技术" in text
+
+
+def test_drill_cli_produces_html(tmp_path):
+ home = tmp_path / "home"
+ course_dir = tmp_path / "模电"
+ assert run([SCRIPTS / "init_course.py", str(course_dir), "--course-id", "analog",
+ "--name", "模拟电子技术"], tmp_path, home).returncode == 0
+ assert run([SCRIPTS / "event.py", "kc-add", "--kc-id", "feedback_topology",
+ "--name", "反馈组态判断", "--exam-weight", "0.9"], course_dir, home).returncode == 0
+ # register two MCQs
+ for qid, ans in (("q1", "A"), ("q2", "B")):
+ (course_dir / f"{qid}.json").write_text(json.dumps({
+ "question_id": qid, "kc_ids": ["feedback_topology"], "source_type": "past_exam",
+ "transfer_level": "T0", "stem": f"题 {qid}\nA.x\nB.y", "answer": ans,
+ }, ensure_ascii=False), encoding="utf-8")
+ assert run([SCRIPTS / "validate_question.py", str(course_dir / f"{qid}.json")],
+ course_dir, home).returncode == 0
+ out_html = course_dir / "drill.html"
+ r = run([SCRIPTS / "drill.py", "--mode", "syllabus", "--count", "2", "--format", "html",
+ "--out", str(out_html), "--seed", "1"], course_dir, home)
+ assert r.returncode == 0, r.stderr
+ assert "feedback_topology(反馈组态判断)" in r.stdout # summary uses labels
+ assert out_html.exists() and "id=\"revealToggle\"" in out_html.read_text(encoding="utf-8")
diff --git a/tests/test_display.py b/tests/test_display.py
new file mode 100644
index 0000000..dbac115
--- /dev/null
+++ b/tests/test_display.py
@@ -0,0 +1,22 @@
+from studylib.display import kc_label
+
+
+def test_label_with_chinese_name():
+ kcs = {"mao_living_soul": {"name": "毛泽东思想活的灵魂"}}
+ assert kc_label("mao_living_soul", kcs) == "mao_living_soul(毛泽东思想活的灵魂)"
+
+
+def test_label_missing_kc_falls_back_to_id():
+ assert kc_label("orphan", {"x": {"name": "X"}}) == "orphan"
+
+
+def test_label_no_kcs_arg():
+ assert kc_label("orphan") == "orphan"
+
+
+def test_label_name_equals_id():
+ assert kc_label("x", {"x": {"name": "x"}}) == "x"
+
+
+def test_label_empty_name_falls_back():
+ assert kc_label("x", {"x": {"name": ""}}) == "x"
diff --git a/tests/test_drill_gather.py b/tests/test_drill_gather.py
new file mode 100644
index 0000000..d2edf7e
--- /dev/null
+++ b/tests/test_drill_gather.py
@@ -0,0 +1,43 @@
+def _q(qid, kcs):
+ return {"question_id": qid, "kc_ids": kcs, "stem": "", "answer": "A"}
+
+
+def test_enough_questions_no_shortfall():
+ from studylib.drill import gather_questions
+ questions = {
+ "a1": _q("a1", ["k1"]), "a2": _q("a2", ["k1"]), "a3": _q("a3", ["k1"]),
+ "b1": _q("b1", ["k2"]), "b2": _q("b2", ["k2"]),
+ }
+ picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=4)
+ assert len(picked) == 4
+ assert short == {}
+
+
+def test_shortfall_when_kc_has_no_questions():
+ from studylib.drill import gather_questions
+ questions = {"a1": _q("a1", ["k1"]), "a2": _q("a2", ["k1"])}
+ picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=4)
+ assert short == {"k2": 2}
+ assert all(q["kc_ids"] == ["k1"] for q in picked)
+
+
+def test_total_cap_respected():
+ from studylib.drill import gather_questions
+ questions = {f"a{i}": _q(f"a{i}", ["k1"]) for i in range(5)}
+ questions.update({f"b{i}": _q(f"b{i}", ["k2"]) for i in range(5)})
+ picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=2)
+ assert len(picked) == 2
+ assert short == {}
+
+
+def test_multi_kc_question_not_duplicated():
+ from studylib.drill import gather_questions
+ questions = {
+ "q1": _q("q1", ["k1", "k2"]),
+ "q2": _q("q2", ["k1"]),
+ "q3": _q("q3", ["k2"]),
+ }
+ picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=4)
+ ids = [q["question_id"] for q in picked]
+ assert len(ids) == len(set(ids)), f"duplicate questions: {ids}"
+ assert set(ids) == {"q1", "q2", "q3"}
diff --git a/tests/test_drill_select.py b/tests/test_drill_select.py
new file mode 100644
index 0000000..83c935d
--- /dev/null
+++ b/tests/test_drill_select.py
@@ -0,0 +1,53 @@
+import pytest
+
+
+def _kc(state, weight=0.5):
+ return {"kc_id": state, "name": state, "teaching_state": state, "exam_weight": weight,
+ "prerequisites": [], "transfer": {}, "calibration": {}, "retention": {}}
+
+
+def test_empty_and_zero_count():
+ from studylib.drill import select_kcs
+ assert select_kcs({}, "syllabus", 5) == []
+ assert select_kcs({"a": _kc("unseen")}, "syllabus", 0) == []
+
+
+def test_count_capped_to_available():
+ from studylib.drill import select_kcs
+ kcs = {"a": _kc("unseen"), "b": _kc("unseen")}
+ assert set(select_kcs(kcs, "syllabus", 10, seed=1)) == {"a", "b"}
+
+
+def test_deterministic_with_seed():
+ from studylib.drill import select_kcs
+ kcs = {f"k{i}": _kc("unseen", weight=(i + 1) / 5) for i in range(6)}
+ r1 = select_kcs(kcs, "syllabus", 3, seed=42)
+ r2 = select_kcs(kcs, "syllabus", 3, seed=42)
+ assert r1 == r2 and len(r1) == 3
+
+
+def test_unknown_mode_raises():
+ from studylib.drill import select_kcs
+ with pytest.raises(ValueError):
+ select_kcs({"a": _kc("unseen")}, "bogus", 1)
+
+
+def test_diagnostic_prefers_weak_in_distribution():
+ # ES gives higher probability to higher weights, not certainty. Sample many
+ # seeds and assert the frequency ordering the weights imply.
+ from collections import Counter
+ from studylib.drill import select_kcs
+ kcs = {"weak": _kc("weak"), "conf": _kc("confirmed"), "unseen": _kc("unseen")}
+ picks = Counter(select_kcs(kcs, "diagnostic", 1, seed=s)[0] for s in range(400))
+ # weak (weight 1.0) selected most; unseen (0.4) next; confirmed (0.0) ~never
+ assert picks["weak"] > picks["unseen"] > picks.get("confirmed", 0)
+ # weak should win a clear majority (theoretical ~71%); >50% with large margin
+ assert picks["weak"] > 200
+
+
+def test_diagnostic_falls_back_when_all_unseen():
+ from studylib.drill import select_kcs
+ kcs = {"a": _kc("unseen", weight=0.9), "b": _kc("unseen", weight=0.1)}
+ # all unseen -> behaves like syllabus; high-weight 'a' strongly preferred
+ chosen = select_kcs(kcs, "diagnostic", 1, seed=0)
+ assert chosen == ["a"]
diff --git a/tests/test_ioutils.py b/tests/test_ioutils.py
index 7582845..5f51f4f 100644
--- a/tests/test_ioutils.py
+++ b/tests/test_ioutils.py
@@ -53,3 +53,15 @@ def test_course_lock_timeout(tmp_path):
pass
finally:
outer.release()
+
+
+def test_read_json_roundtrip(tmp_path):
+ from studylib.ioutils import atomic_write_json, read_json
+ p = tmp_path / "x.json"
+ atomic_write_json(p, {"a": 1, "中文": "好"})
+ assert read_json(p) == {"a": 1, "中文": "好"}
+
+
+def test_read_json_missing_returns_empty(tmp_path):
+ from studylib.ioutils import read_json
+ assert read_json(tmp_path / "nope.json") == {}
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
new file mode 100644
index 0000000..2767096
--- /dev/null
+++ b/tests/test_manifest.py
@@ -0,0 +1,25 @@
+from studylib.manifest import build_manifest
+
+
+def test_manifest_shape_and_meta():
+ course = {"id": "mao-zhongte", "name": "毛中特"}
+ qs = [{"question_id": "q1", "kc_ids": ["mao_living_soul"], "stem": "s", "answer": "A",
+ "solution": "sol", "difficulty": 0.4, "transfer_level": "T0",
+ "validation": {"generator": {}}}]
+ m = build_manifest(course, "diagnostic", 5, qs,
+ kcs={"mao_living_soul": {"name": "毛泽东思想活的灵魂"}})
+ assert m["meta"] == {k: m["meta"][k] for k in
+ ("course_id", "course_name", "mode", "count", "generated_at")}
+ assert m["meta"]["course_name"] == "毛中特" and m["meta"]["count"] == 5
+ assert len(m["questions"]) == 1
+ q = m["questions"][0]
+ assert q["question_id"] == "q1"
+ assert q["kc_labels"] == ["mao_living_soul(毛泽东思想活的灵魂)"]
+ assert "validation" not in q # rendering does not need it
+ assert q["answer"] == "A" and q["solution"] == "sol"
+
+
+def test_manifest_no_kcs_falls_back_to_ids():
+ m = build_manifest({"id": "c", "name": "C"}, "syllabus", 3,
+ [{"question_id": "q", "kc_ids": ["k"], "stem": "", "answer": "B"}])
+ assert m["questions"][0]["kc_labels"] == ["k"]
diff --git a/tests/test_nextstep.py b/tests/test_nextstep.py
index 98e9aeb..607c6c9 100644
--- a/tests/test_nextstep.py
+++ b/tests/test_nextstep.py
@@ -17,6 +17,10 @@ def _kc_state(kc_id, state, *, weight=0.5, blind=0.0, t1=None, t2=None,
}
+def _patch_name(kc_states, kc_id, name):
+ kc_states[kc_id]["name"] = name
+
+
def test_repair_beats_confirmed():
from studylib.nextstep import compute_next_best_step
kc_states = {
@@ -60,3 +64,12 @@ def test_advance_for_unseen():
rec = compute_next_best_step(COURSE, {"u": _kc_state("u", "unseen")}, {}, [])
assert rec["action"] == "advance"
assert rec["kc_id"] == "u"
+
+
+def test_next_step_emits_kc_label():
+ from studylib.nextstep import compute_next_best_step
+ kc_states = {"u": _kc_state("u", "unseen")}
+ _patch_name(kc_states, "u", "未知点")
+ rec = compute_next_best_step(COURSE, kc_states, {}, [])
+ assert rec["kc_label"] == "u(未知点)"
+ assert rec["kc_name"] == "未知点" # back-compat retained
diff --git a/tests/test_render_html.py b/tests/test_render_html.py
new file mode 100644
index 0000000..dac25bb
--- /dev/null
+++ b/tests/test_render_html.py
@@ -0,0 +1,96 @@
+from studylib.render_html import parse_options, render_quiz_html
+
+
+def test_parse_options_half_and_full_width():
+ body, opts = parse_options("毛泽东思想活的灵魂是( )\nA.实事求是\nB.群众路线\nC.独立自主\nD.统一战线")
+ assert body == "毛泽东思想活的灵魂是( )"
+ assert opts == [("A", "实事求是"), ("B", "群众路线"), ("C", "独立自主"), ("D", "统一战线")]
+
+
+def test_parse_options_full_width_dot():
+ _, opts = parse_options("Q\nA.甲\nB.乙")
+ assert opts == [("A", "甲"), ("B", "乙")]
+
+
+def test_parse_options_none():
+ body, opts = parse_options("简答题:论述…")
+ assert body == "简答题:论述…" and opts == []
+
+
+def test_parse_option_continuation_line():
+ _, opts = parse_options("Q\nA.第一行\n续行\nB.第二项")
+ assert opts == [("A", "第一行 续行"), ("B", "第二项")]
+
+
+def _manifest(multi=False):
+ return {
+ "meta": {"course_name": "毛中特", "mode": "diagnostic", "count": 1,
+ "generated_at": "2026-07-20T00:00:00+08:00"},
+ "questions": [{
+ "kc_labels": ["mao_living_soul(毛泽东思想活的灵魂)"],
+ "stem": "活的灵魂三个方面是( )\nA.实事求是\nB.群众路线\nC.独立自主",
+ "answer": "ABC" if multi else "A",
+ "solution": "实事求是/群众路线/独立自主。",
+ }],
+ }
+
+
+def test_render_html_embeds_toggle_options_answers():
+ html = render_quiz_html(_manifest(multi=True), reveal_default=True)
+ assert 'id="revealToggle"' in html
+ # toggle ON -> the toggle input carries 'checked'
+ assert 'revealToggle" checked' in html
+ assert "mao_living_soul(毛泽东思想活的灵魂)" in html
+ # multi -> checkbox; single would be radio
+ assert 'type="checkbox"' in html
+ # answer + solution embedded (hidden by CSS until reveal)
+ assert "ABC" in html and "实事求是/群众路线/独立自主" in html
+
+
+def test_render_html_reveal_default_off_not_checked():
+ html = render_quiz_html(_manifest(multi=False), reveal_default=False)
+ assert 'type="radio"' in html
+ # toggle OFF -> the toggle input must NOT carry 'checked'
+ toggle_line = html.split('id="revealToggle"', 1)[1].split(">", 1)[0]
+ assert "checked" not in toggle_line
+
+
+def test_lowercase_answer_normalized_for_grading():
+ # options normalized to uppercase; answer must match that normalization
+ m = {
+ "meta": {"course_name": "C", "mode": "syllabus", "count": 1,
+ "generated_at": "2026-07-20T00:00:00+08:00"},
+ "questions": [{"kc_labels": ["k(名)"], "stem": "Q\nA.x\nB.y\nC.z",
+ "answer": "abc", "solution": "s"}],
+ }
+ html = render_quiz_html(m, reveal_default=True)
+ # grading attribute uses uppercase; displayed answer is uppercase too
+ assert 'data-answer="A,B,C"' in html
+ assert "答案:ABC" in html
+
+
+def test_user_text_is_html_escaped():
+ m = {
+ "meta": {"course_name": "C", "mode": "syllabus", "count": 1,
+ "generated_at": "2026-07-20T00:00:00+08:00"},
+ "questions": [{"kc_labels": ["k(名)"], "stem": "若 a < b 且 b > c\nA.x\nB.y",
+ "answer": "A", "solution": "解析:a & b 的关系"}],
+ }
+ html = render_quiz_html(m, reveal_default=True)
+ assert "<" in html and ">" in html and "&" in html
+ # literal user substring must NOT appear unescaped anywhere in the output
+ assert "a < b" not in html
+ assert "a < b" in html
+
+
+def test_two_questions_have_distinct_radio_groups():
+ m = {
+ "meta": {"course_name": "C", "mode": "syllabus", "count": 2,
+ "generated_at": "2026-07-20T00:00:00+08:00"},
+ "questions": [
+ {"kc_labels": [], "stem": "Q1\nA.x\nB.y", "answer": "A", "solution": ""},
+ {"kc_labels": [], "stem": "Q2\nA.x\nB.y", "answer": "B", "solution": ""},
+ ],
+ }
+ html = render_quiz_html(m, reveal_default=False)
+ assert 'name="q1"' in html and 'name="q2"' in html
diff --git a/tests/test_render_paper.py b/tests/test_render_paper.py
new file mode 100644
index 0000000..60cec0d
--- /dev/null
+++ b/tests/test_render_paper.py
@@ -0,0 +1,29 @@
+from studylib.render_paper import manifest_to_markdown
+
+M = {
+ "meta": {"course_name": "毛中特", "mode": "syllabus", "count": 1,
+ "generated_at": "2026-07-20T00:00:00+08:00"},
+ "questions": [{"question_id": "q1", "kc_labels": ["k(名)"],
+ "stem": "活的灵魂三方面是( )\nA.实事求是\nB.群众路线",
+ "answer": "A", "solution": "实事求是是根本观点。"}],
+}
+
+
+def test_questions_variant_has_no_answer():
+ md = manifest_to_markdown(M, "questions")
+ assert "活的灵魂三方面是" in md and "A.实事求是" in md
+ assert "答案" not in md and "实事求是是根本观点" not in md
+
+
+def test_answers_variant_has_answer_and_solution():
+ md = manifest_to_markdown(M, "answers")
+ assert "答案:A" in md and "实事求是是根本观点" in md
+
+
+def test_markdown_to_pdf_produces_file(tmp_path):
+ from studylib.render_paper import manifest_to_markdown, markdown_to_pdf
+ md = manifest_to_markdown(M, "answers")
+ pdf = tmp_path / "out.pdf"
+ markdown_to_pdf(md, pdf, fonts_dir=None) # CID fallback, no font files needed
+ assert pdf.exists() and pdf.stat().st_size > 0
+ assert pdf.read_bytes()[:5] == b"%PDF-"