"""Tests for amateurfunk_shorthand. Like the multiple-choice tests, these inspect the generated `.apkg` directly (ZIP entries plus the SQLite collection) and never require Anki itself. They also validate the repo's real `shorthand.json` against the schema so editorial mistakes surface in CI. """ import json import sqlite3 import tempfile import unittest import zipfile from pathlib import Path import amateurfunk_shorthand as sh def make_shorthand(): return { "q_codes": [ { "code": "QSO", "question": "Can you communicate directly with ...?", "statement": "I can communicate directly with ...", "exam": True, "explanation": "The radio contact itself.", "example": "DL1ABC: `Tnx for QSO.`", }, { "code": "QRG", "question": "Will you tell me my exact frequency?", "statement": "Your exact frequency is ... kHz.", }, ], "abbreviations": [ { "code": "73", "meaning": "Best regards.", "exam": True, }, { "code": "SK", "meaning": "End of contact; also silent key.", "tags": ["prosign"], }, ], } def write_shorthand(root: Path, data=None): path = root / "shorthand.json" path.write_text( json.dumps(data if data is not None else make_shorthand()), 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 TestShorthandBuild(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.shorthand_path = write_shorthand(self.root) self.out_dir = self.root / "anki" def _build(self, **kwargs): return sh.build_deck( self.shorthand_path, self.out_dir, self.root / "data", # absent → epoch fallback **kwargs, ) def _collection(self): apkg = next(self.out_dir.glob("*.apkg")) return extract_collection(apkg, self.root) def test_qgroup_yields_two_notes_abbrev_yields_one(self): result = self._build() # 2 Q-groups → 4 notes, 2 abbreviations → 2 notes. self.assertEqual(result["notes"], 6) # Two cards per note (forward + reverse). self.assertEqual(result["cards"], 12) self.assertEqual(result["q_codes"], 2) self.assertEqual(result["abbreviations"], 2) def test_qgroup_statement_and_question_become_distinct_notes(self): self._build() db_path, _media, _names = self._collection() conn = sqlite3.connect(db_path) try: # sfld has INTEGER affinity, so numeric codes (e.g. 73) come # back as ints; stringify before comparing. sflds = sorted(str(r[0]) for r in conn.execute("select sfld from notes")) finally: conn.close() self.assertIn("QSO", sflds) self.assertIn("QSO?", sflds) def test_each_note_has_two_cards_one_per_template(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" " order by sfld, ord" ).fetchall() ords_for_qso = sorted(o for s, o in rows if s == "QSO") finally: conn.close() self.assertEqual(ords_for_qso, [0, 1]) def test_model_has_two_templates_and_per_template_requirements(self): self._build() db_path, _media, _names = self._collection() conn = sqlite3.connect(db_path) try: models = json.loads( conn.execute("select models from col").fetchone()[0] ) finally: conn.close() model = next(iter(models.values())) self.assertEqual([t["name"] for t in model["tmpls"]], ["Bedeutung", "Kürzel"]) # Forward template needs Code (field 0); reverse needs Meaning (1). self.assertEqual(model["req"], [[0, "all", [0]], [1, "all", [1]]]) def test_tags_mark_kind_exam_and_extra_editorial_tags(self): self._build() db_path, _media, _names = self._collection() conn = sqlite3.connect(db_path) try: tags = dict( conn.execute("select sfld, tags from notes").fetchall() ) finally: conn.close() self.assertIn(" q-gruppe ", tags["QSO"]) self.assertIn(" pruefung ", tags["QSO"]) self.assertIn(" abkuerzung ", tags["SK"]) self.assertIn(" prosign ", tags["SK"]) # QRG is not an exam code → no pruefung tag. self.assertNotIn("pruefung", tags["QRG"]) def test_no_media_entries_in_package(self): self._build() _db, media, names = self._collection() self.assertEqual(media, {}) self.assertEqual(names, {"collection.anki2", "media"}) def test_example_backticks_become_code_and_text_is_escaped(self): self._build() db_path, _media, _names = self._collection() conn = sqlite3.connect(db_path) try: flds = next( r[0] for r in conn.execute("select flds from notes") if r[0].startswith("QSO" + sh.FIELD_SEP) ) finally: conn.close() self.assertIn("Tnx for QSO.", flds) def test_build_is_byte_deterministic_for_same_input(self): out_a = self.root / "a" out_b = self.root / "b" sh.build_deck(self.shorthand_path, out_a, self.root / "data", override_epoch=42) sh.build_deck(self.shorthand_path, out_b, self.root / "data", override_epoch=42) for first in sorted(out_a.glob("*.apkg")): second = out_b / first.name self.assertEqual(first.read_bytes(), second.read_bytes(), first.name) def test_stable_ids_are_unchanged_across_builds(self): self._build() db_path, _m, _n = self._collection() conn = sqlite3.connect(db_path) try: first = sorted(r[0] for r in conn.execute("select id from notes")) finally: conn.close() # Rebuild into a fresh dir; ids derive from the code form only. out_b = self.root / "again" sh.build_deck(self.shorthand_path, out_b, self.root / "data") db_b, _m, _n = extract_collection(next(out_b.glob("*.apkg")), out_b) conn = sqlite3.connect(db_b) try: second = sorted(r[0] for r in conn.execute("select id from notes")) finally: conn.close() self.assertEqual(first, second) def test_duplicate_code_form_is_rejected(self): data = make_shorthand() data["abbreviations"].append({"code": "73", "meaning": "duplicate"}) path = write_shorthand(self.root, data) with self.assertRaises(sh.AnkiBuildError) as ctx: sh.build_deck(path, self.out_dir, self.root / "data") self.assertIn("73", str(ctx.exception)) def test_qgroup_question_form_collides_with_abbrev_question_mark(self): # A Q-group `QRX` expands to `QRX?`; an abbreviation literally # named `QRX?` would collide and must be rejected. data = make_shorthand() data["q_codes"].append( {"code": "QRX", "question": "When?", "statement": "Wait."} ) data["abbreviations"].append({"code": "QRX?", "meaning": "clash"}) path = write_shorthand(self.root, data) with self.assertRaises(sh.AnkiBuildError): sh.build_deck(path, self.out_dir, self.root / "data") def test_missing_required_field_is_rejected(self): data = make_shorthand() del data["q_codes"][0]["statement"] path = write_shorthand(self.root, data) with self.assertRaises(sh.AnkiBuildError) as ctx: sh.build_deck(path, self.out_dir, self.root / "data") self.assertIn("statement", str(ctx.exception)) def test_unknown_field_is_rejected(self): data = make_shorthand() data["abbreviations"][0]["author"] = "claude" path = write_shorthand(self.root, data) with self.assertRaises(sh.AnkiBuildError) as ctx: sh.build_deck(path, self.out_dir, self.root / "data") self.assertIn("author", str(ctx.exception)) def test_non_bool_exam_is_rejected(self): data = make_shorthand() data["abbreviations"][0]["exam"] = "yes" path = write_shorthand(self.root, data) with self.assertRaises(sh.AnkiBuildError): sh.build_deck(path, self.out_dir, self.root / "data") def test_missing_file_is_a_hard_error(self): with self.assertRaises(sh.AnkiBuildError): sh.build_deck( self.root / "nope.json", self.out_dir, self.root / "data" ) def test_main_returns_success(self): rc = sh.main([ "--shorthand", str(self.shorthand_path), "--out", str(self.out_dir), "--data", str(self.root / "data"), ]) self.assertEqual(rc, sh.EXIT_OK) class TestRealShorthandFile(unittest.TestCase): """Validate the repo's actual shorthand.json against the schema.""" def test_repo_shorthand_builds(self): repo = Path(__file__).resolve().parent path = repo / "shorthand.json" if not path.exists(): self.skipTest("shorthand.json not available") q_codes, abbreviations = sh.load_shorthand(path) notes = sh.build_notes(q_codes, abbreviations) self.assertEqual(len(notes), 2 * len(q_codes) + len(abbreviations)) # Every note carries exactly two cards. self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes)) if __name__ == "__main__": unittest.main()