"""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()