"""Tests for amateurfunk_anki. These tests inspect the generated `.apkg` package structure directly: ZIP entries, media map, and the SQLite collection database. They do not require Anki itself to be installed. """ from __future__ import annotations import json import sqlite3 import tempfile import zipfile from pathlib import Path import unittest import amateurfunk_anki as aa def make_catalog(): return { "metadata": { "edition": "3. Auflage, März 2024", "issued_on": "2024-03-20", "valid_from": "2024-06-24", "license": "DL-DE->BY-2.0", }, "sections": [ { "title": "Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "sections": [ { "title": "Grundlagen", "questions": [ question("NA101", "1", "Was ist A?", "NA101_q"), question("EA202", "2", "Was ist E?", None), ], } ], }, { "title": "Prüfungsfragen im Prüfungsteil: Betriebliche Kenntnisse", "questions": [ question("BA101", "1", "Betrieb?", "BA101_q"), ], }, { "title": "Prüfungsfragen im Prüfungsteil: Kenntnisse von Vorschriften", "questions": [ question("VA101", "1", "Vorschrift?", None), ], }, ], } def question(number, klass, prompt, picture): data = { "number": number, "class": klass, "question": prompt, "answer_a": f"{number} richtig", "answer_b": f"{number} falsch B", "answer_c": f"{number} falsch C", "answer_d": f"{number} falsch D", } if picture: data["picture_question"] = picture return data def make_fetched_data(root: Path): data_dir = root / "data" edition_dir = data_dir / "2024-03-20-3-auflage" media_dir = edition_dir / "svgs" media_dir.mkdir(parents=True) (media_dir / "NA101_q.svg").write_text("na", encoding="utf-8") (media_dir / "BA101_q.svg").write_text("ba", encoding="utf-8") (media_dir / "NA101_a.svg").write_text("answer-a", encoding="utf-8") catalog = make_catalog() (edition_dir / "fragenkatalog3b.json").write_text( json.dumps(catalog, ensure_ascii=False), encoding="utf-8", ) (edition_dir / "manifest.json").write_text( json.dumps( { "slug": "2024-03-20-3-auflage", "json_filename": "fragenkatalog3b.json", "fetched_at": "2026-05-20T12:00:00+00:00", "metadata": catalog["metadata"], } ), encoding="utf-8", ) (data_dir / "manifest-latest.json").write_text( json.dumps({"slug": "2024-03-20-3-auflage"}), encoding="utf-8", ) return data_dir 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 TestAnkiBuild(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.data_dir = make_fetched_data(self.root) self.out_dir = self.root / "anki" def test_builds_one_apkg_per_top_level_category(self): results = aa.build_all(self.data_dir, self.out_dir, seed="test-seed") paths = sorted(path.name for path in self.out_dir.glob("*.apkg")) self.assertEqual( paths, [ "amateurfunk-betriebliche-kenntnisse.apkg", "amateurfunk-kenntnisse-von-vorschriften.apkg", "amateurfunk-technische-kenntnisse.apkg", ], ) self.assertEqual([r["questions"] for r in results], [2, 1, 1]) def test_apkg_contains_notes_cards_and_media(self): aa.build_all(self.data_dir, self.out_dir, seed="test-seed") apkg = self.out_dir / "amateurfunk-technische-kenntnisse.apkg" db_path, media, names = extract_collection(apkg, self.root) self.assertIn("collection.anki2", names) self.assertIn("media", names) self.assertEqual(set(media.values()), {"NA101_q.svg"}) self.assertIn("0", names) with zipfile.ZipFile(apkg) as zf: packaged_svg = zf.read("0").decode("utf-8") self.assertIn('data-af-white-background="1"', packaged_svg) self.assertIn('fill="#fff"', packaged_svg) self.assertLess( packaged_svg.index('data-af-white-background="1"'), packaged_svg.index("na"), ) conn = sqlite3.connect(db_path) try: note_count = conn.execute("select count(*) from notes").fetchone()[0] card_count = conn.execute("select count(*) from cards").fetchone()[0] self.assertEqual(note_count, 2) self.assertEqual(card_count, 2) fields = [row[0] for row in conn.execute("select flds from notes")] finally: conn.close() joined = "\n".join(fields) self.assertIn('src="NA101_q.svg"', joined) self.assertIn("Richtige Antwort:", joined) self.assertIn("NA101 richtig", joined) self.assertIn("Technische Kenntnisse / Grundlagen", joined) self.assertNotIn("Prüfungsfragen im Prüfungsteil", joined) def test_answers_are_shuffled_and_back_reveals_display_label(self): item = aa.QuestionItem( question=question("TEST123", "1", "Prompt?", None), path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"), ) media = aa.MediaRegistry(self.root / "missing") front, back, correct_label = aa.render_question(item, media, "seed") self.assertIn("Richtige Antwort:", back) self.assertIn(f"Richtige Antwort: {correct_label}", back) self.assertIn("TEST123 richtig", back) # The answer texts should not be in their upstream A/B/C/D order. positions = [ front.index("TEST123 richtig"), front.index("TEST123 falsch B"), front.index("TEST123 falsch C"), front.index("TEST123 falsch D"), ] self.assertNotEqual(positions, sorted(positions)) def test_latex_is_converted_to_mathjax_and_text_is_escaped(self): rendered = aa.text_html("Leistung < $P = U \\cdot I$ & $a < b$") self.assertIn("Leistung < ", rendered) self.assertIn(r"\(P = U \cdot I\)", rendered) self.assertIn(r"\(a < b\)", rendered) self.assertIn(" & ", rendered) self.assertNotIn("$P =", rendered) def test_catalog_underline_markup_is_preserved_safely(self): rendered = aa.text_html("foo nicht 2 < 3 ") self.assertIn("nicht", rendered) self.assertIn("2 < 3", rendered) self.assertIn("<script>x</script>", rendered) def test_underline_inside_math_is_escaped_not_rendered_as_html(self): rendered = aa.text_html("$x$") self.assertEqual(rendered, r"\(<u>x</u>\)") def test_tags_use_license_letters_and_skip_singleton_number_tag(self): item = aa.QuestionItem( question=question("NA101", "1", "Prompt?", None), path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"), ) tags = aa.tags_for_item(item) self.assertIn(" klasse-N ", tags) self.assertNotIn(" klasse-1 ", tags) self.assertNotIn("nummer-NA101", tags) def test_breadcrumb_strips_verbose_pruefungsteil_prefix(self): item = aa.QuestionItem( question=question("TEST123", "1", "Prompt?", None), path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"), ) front, _back, _label = aa.render_question( item, aa.MediaRegistry(self.root / "missing"), "seed", ) self.assertIn("Technische Kenntnisse / Leaf", front) self.assertNotIn("Prüfungsfragen im Prüfungsteil", front) def test_answer_choice_pictures_follow_shuffle(self): media_dir = self.root / "media" media_dir.mkdir() (media_dir / "TEST123_a.svg").write_text("a", encoding="utf-8") (media_dir / "TEST123_b.svg").write_text("b", encoding="utf-8") q = question("TEST123", "1", "Prompt?", None) q["picture_a"] = "TEST123_a" q["picture_b"] = "TEST123_b" item = aa.QuestionItem( question=q, path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"), ) media = aa.MediaRegistry(media_dir) front, back, _label = aa.render_question(item, media, "seed") self.assertIn('src="TEST123_a.svg"', front) self.assertIn('src="TEST123_b.svg"', front) self.assertIn('src="TEST123_a.svg"', back) self.assertEqual({p.name for p in media.used_paths}, {"TEST123_a.svg", "TEST123_b.svg"}) def test_svg_white_background_injection_is_idempotent(self): svg = '' once = aa.svg_with_white_background(svg) twice = aa.svg_with_white_background(once) self.assertEqual(once, twice) self.assertIn('