Add glossary decks

This commit is contained in:
2026-06-17 11:48:27 +02:00
parent fb81e9c1aa
commit 2b73096d3c
9 changed files with 2748 additions and 11 deletions
+213
View File
@@ -0,0 +1,213 @@
"""Tests for amateurfunk_technical.
Like the other deck tests, these inspect the generated `.apkg` directly
(ZIP entries plus the SQLite collection) and never require Anki. They
also validate the repo's real `technical.json` against the schema.
"""
import json
import sqlite3
import tempfile
import unittest
import zipfile
from pathlib import Path
import amateurfunk_technical as tech
from amateurfunk_anki import FIELD_SEP
def make_technical():
return {
"terms": [
{
"code": "SSB",
"category": "Betriebsart",
"meaning": "Single sideband.",
"exam": True,
"explanation": "One sideband, carrier suppressed.",
},
{
"code": "NF",
"category": "Signal & Frequenz",
"meaning": "Niederfrequenz — audio frequency.",
"exam": True,
},
{
"code": "SDR",
"category": "Digital & Daten",
"meaning": "Software defined radio.",
"tags": ["modern"],
},
],
}
def write_technical(root: Path, data=None):
path = root / "technical.json"
path.write_text(
json.dumps(data if data is not None else make_technical()),
encoding="utf-8",
)
return path
def extract_collection(apkg_path: Path, tmp: Path):
with zipfile.ZipFile(apkg_path) as zf:
zf.extract("collection.anki2", tmp)
media = json.loads(zf.read("media").decode("utf-8"))
names = set(zf.namelist())
return tmp / "collection.anki2", media, names
class TestTechnicalBuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.path = write_technical(self.root)
self.out_dir = self.root / "anki"
def _build(self, **kwargs):
return tech.build_deck(self.path, self.out_dir, self.root / "data", **kwargs)
def _collection(self):
apkg = next(self.out_dir.glob("*.apkg"))
return extract_collection(apkg, self.root)
def test_one_note_two_cards_per_term(self):
result = self._build()
self.assertEqual(result["terms"], 3)
self.assertEqual(result["notes"], 3)
self.assertEqual(result["cards"], 6)
def test_deck_and_model_are_distinct_from_operating_deck(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
models = json.loads(conn.execute("select models from col").fetchone()[0])
finally:
conn.close()
self.assertIn(tech.DECK_NAME, [d["name"] for d in decks.values()])
self.assertEqual(
next(iter(models.values()))["name"], tech.MODEL_NAME
)
def test_category_becomes_kind_field_and_tag(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = dict(
(r[0], (r[1], r[2]))
for r in conn.execute("select sfld, flds, tags from notes")
)
finally:
conn.close()
flds, tags = rows["SSB"]
# Kind field (index 2) carries the German category.
self.assertEqual(flds.split(FIELD_SEP)[2], "Betriebsart")
self.assertIn(" technik ", tags)
self.assertIn(" kategorie-betriebsart ", tags)
self.assertIn(" pruefung ", tags)
# A non-exam term carries no pruefung tag but keeps extra tags.
_flds, sdr_tags = rows["SDR"]
self.assertNotIn("pruefung", sdr_tags)
self.assertIn(" modern ", sdr_tags)
def test_each_note_has_forward_and_reverse_card(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"select sfld, ord from notes join cards on cards.nid = notes.id"
).fetchall()
finally:
conn.close()
ords_for_ssb = sorted(o for s, o in rows if s == "SSB")
self.assertEqual(ords_for_ssb, [0, 1])
def test_no_media_in_package(self):
self._build()
_db, media, names = self._collection()
self.assertEqual(media, {})
self.assertEqual(names, {"collection.anki2", "media"})
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "a"
out_b = self.root / "b"
tech.build_deck(self.path, out_a, self.root / "data", override_epoch=99)
tech.build_deck(self.path, out_b, self.root / "data", override_epoch=99)
for first in sorted(out_a.glob("*.apkg")):
self.assertEqual(
first.read_bytes(), (out_b / first.name).read_bytes(), first.name
)
def test_ids_are_namespaced_apart_from_operating_deck(self):
# A code that exists in both decks (e.g. a hypothetical clash)
# must not share a GUID across the two namespaces.
from amateurfunk_shorthand import make_note as sh_make_note
op = sh_make_note("SSB", "x", "k", "", None, None, " t ", "shorthand")
te = tech.build_notes(
[{"code": "SSB", "category": "Betriebsart", "meaning": "x"}]
)[0]
self.assertNotEqual(op["guid"], te["guid"])
self.assertNotEqual(op["note_id"], te["note_id"])
def test_duplicate_code_is_rejected(self):
data = make_technical()
data["terms"].append(
{"code": "SSB", "category": "Betriebsart", "meaning": "dup"}
)
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("SSB", str(ctx.exception))
def test_missing_category_is_rejected(self):
data = make_technical()
del data["terms"][0]["category"]
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("category", str(ctx.exception))
def test_unknown_field_is_rejected(self):
data = make_technical()
data["terms"][0]["author"] = "claude"
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("author", str(ctx.exception))
def test_missing_file_is_a_hard_error(self):
with self.assertRaises(tech.AnkiBuildError):
tech.build_deck(self.root / "nope.json", self.out_dir, self.root / "data")
def test_main_returns_success(self):
rc = tech.main([
"--technical", str(self.path),
"--out", str(self.out_dir),
"--data", str(self.root / "data"),
])
self.assertEqual(rc, tech.EXIT_OK)
class TestRealTechnicalFile(unittest.TestCase):
"""Validate the repo's actual technical.json against the schema."""
def test_repo_technical_builds(self):
repo = Path(__file__).resolve().parent
path = repo / "technical.json"
if not path.exists():
self.skipTest("technical.json not available")
terms = tech.load_technical(path)
notes = tech.build_notes(terms)
self.assertEqual(len(notes), len(terms))
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
if __name__ == "__main__":
unittest.main()