Add explanations
This commit is contained in:
+194
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user