Add explanations

This commit is contained in:
2026-05-22 16:17:05 +02:00
parent 0d635a8587
commit 27988780cf
7 changed files with 933 additions and 18 deletions
+194 -6
View File
@@ -115,9 +115,22 @@ class TestAnkiBuild(unittest.TestCase):
self.root = Path(self.tmp.name)
self.data_dir = make_fetched_data(self.root)
self.out_dir = self.root / "anki"
# Hermetic explanations file: empty by default so tests don't
# pick up the real repo's explanations.json via the CLI
# default. Individual tests overwrite this file as needed.
self.explanations_path = self.root / "explanations.json"
self.explanations_path.write_text("{}", encoding="utf-8")
def _build_all(self):
return aa.build_all(
self.data_dir,
self.out_dir,
seed="test-seed",
explanations_path=self.explanations_path,
)
def test_builds_one_apkg_per_category(self):
results = aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
results = self._build_all()
paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
self.assertEqual(
paths,
@@ -142,7 +155,7 @@ class TestAnkiBuild(unittest.TestCase):
)
def test_technische_decks_partition_strictly_by_class_field(self):
aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
self._build_all()
per_class_numbers = {}
for letter in ("n", "e", "a"):
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
@@ -162,7 +175,7 @@ class TestAnkiBuild(unittest.TestCase):
)
def test_apkg_contains_notes_cards_and_media(self):
aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
db_path, media, names = extract_collection(apkg, self.root)
@@ -286,15 +299,190 @@ class TestAnkiBuild(unittest.TestCase):
self.assertIn('<rect data-af-white-background="1"', once)
self.assertLess(once.index("<rect"), once.index("<path"))
def test_explanation_is_appended_to_back_when_present(self):
self.explanations_path.write_text(
json.dumps({
"NA101": {
"revision": 1,
"explanation": "Doubling the voltage halves the current for fixed power.",
"source": "https://example.invalid/ohms-law",
"confidence": 8,
}
}),
encoding="utf-8",
)
results = self._build_all()
n_result = next(
r for r in results
if r["deck"] == "Amateurfunk::Technische Kenntnisse::N"
)
self.assertEqual(n_result["explanations"], 1)
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "n")
conn = sqlite3.connect(db_path)
try:
fields = [row[0] for row in conn.execute("select flds from notes")]
finally:
conn.close()
joined = "\n".join(fields)
self.assertIn("af-explanation", joined)
self.assertIn("Doubling the voltage halves", joined)
self.assertIn(
'<a href="https://example.invalid/ohms-law">'
'https://example.invalid/ohms-law</a>',
joined,
)
def test_low_confidence_explanation_shows_badge(self):
item = aa.QuestionItem(
question=question("TEST123", "1", "Prompt?", None),
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
)
low = {"TEST123": {
"revision": 1,
"explanation": "Weak guess.",
"source": "TBD",
"confidence": aa.LOW_CONFIDENCE_THRESHOLD - 1,
}}
high = {"TEST123": {
"revision": 1,
"explanation": "Solid reasoning.",
"source": "AFuV §1",
"confidence": aa.LOW_CONFIDENCE_THRESHOLD,
}}
media = aa.MediaRegistry(self.root / "missing")
_f1, back_low, _l1 = aa.render_question(item, media, "seed", low)
_f2, back_high, _l2 = aa.render_question(item, media, "seed", high)
self.assertIn("af-explanation-low-confidence", back_low)
self.assertIn("low confidence", back_low)
self.assertNotIn("af-explanation-low-confidence", back_high)
self.assertNotIn("low confidence", back_high)
def test_missing_explanation_leaves_card_unchanged(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "e")
conn = sqlite3.connect(db_path)
try:
fields = [row[0] for row in conn.execute("select flds from notes")]
finally:
conn.close()
self.assertNotIn("af-explanation", "\n".join(fields))
def test_non_url_source_is_rendered_as_plain_text(self):
item = aa.QuestionItem(
question=question("TEST123", "1", "Prompt?", None),
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
)
explanations = {
"TEST123": {
"revision": 1,
"explanation": "Because $P = U \\cdot I$ and the spec fixes P.",
"source": "AFuV §16(2)",
"confidence": 9,
}
}
_front, back, _label = aa.render_question(
item, aa.MediaRegistry(self.root / "missing"), "seed", explanations,
)
self.assertIn("AFuV §16(2)", back)
self.assertNotIn("<a href=", back)
# Inline LaTeX in the explanation body goes through text_html().
self.assertIn(r"\(P = U \cdot I\)", back)
def test_unknown_explanation_key_fails_the_build(self):
self.explanations_path.write_text(
json.dumps({
"NA10I": { # typo: 'I' instead of '1' — not in the catalog
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 5,
}
}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError) as ctx:
self._build_all()
self.assertIn("NA10I", str(ctx.exception))
def test_schema_rejects_bool_in_integer_fields(self):
for bad_field in ("revision", "confidence"):
entry = {
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 5,
}
entry[bad_field] = True
self.explanations_path.write_text(
json.dumps({"NA101": entry}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
self._build_all()
def test_schema_rejects_extra_fields(self):
self.explanations_path.write_text(
json.dumps({
"NA101": {
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 5,
"author": "claude",
}
}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError) as ctx:
self._build_all()
self.assertIn("author", str(ctx.exception))
def test_invalid_explanation_schema_raises_build_error(self):
self.explanations_path.write_text(
json.dumps({
"NA101": {
"revision": 1,
"explanation": "ok",
"source": "ok",
"confidence": 11, # out of range
}
}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
self._build_all()
def test_missing_explanations_file_is_treated_as_empty(self):
missing = self.root / "does-not-exist.json"
results = aa.build_all(
self.data_dir,
self.out_dir,
seed="test-seed",
explanations_path=missing,
)
self.assertTrue(all(r["explanations"] == 0 for r in results))
def test_main_returns_success(self):
rc = aa.main(["--data", str(self.data_dir), "--out", str(self.out_dir)])
rc = aa.main([
"--data", str(self.data_dir),
"--out", str(self.out_dir),
"--explanations", str(self.explanations_path),
])
self.assertEqual(rc, aa.EXIT_OK)
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "anki-a"
out_b = self.root / "anki-b"
aa.main(["--data", str(self.data_dir), "--out", str(out_a), "--epoch", "1234567890"])
aa.main(["--data", str(self.data_dir), "--out", str(out_b), "--epoch", "1234567890"])
common = [
"--data", str(self.data_dir),
"--explanations", str(self.explanations_path),
"--epoch", "1234567890",
]
aa.main([*common, "--out", str(out_a)])
aa.main([*common, "--out", str(out_b)])
for first in sorted(out_a.glob("*.apkg")):
second = out_b / first.name