Files
Amateurfunk-Anki/test_amateurfunk_anki.py
T

306 lines
12 KiB
Python

"""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.
"""
import json
import sqlite3
import tempfile
import unittest
import zipfile
from pathlib import Path
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),
question("AA303", "3", "Was ist A-Klasse?", 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("<svg>na</svg>", encoding="utf-8")
(media_dir / "BA101_q.svg").write_text("<svg>ba</svg>", encoding="utf-8")
(media_dir / "NA101_a.svg").write_text("<svg>answer-a</svg>", 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_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-a.apkg",
"amateurfunk-technische-kenntnisse-e.apkg",
"amateurfunk-technische-kenntnisse-n.apkg",
],
)
by_deck = {r["deck"]: r["questions"] for r in results}
self.assertEqual(
by_deck,
{
"Amateurfunk::Technische Kenntnisse::N": 1,
"Amateurfunk::Technische Kenntnisse::E": 1,
"Amateurfunk::Technische Kenntnisse::A": 1,
"Amateurfunk::Betriebliche Kenntnisse": 1,
"Amateurfunk::Kenntnisse von Vorschriften": 1,
},
)
def test_technische_decks_partition_strictly_by_class_field(self):
aa.build_all(self.data_dir, self.out_dir, seed="test-seed")
per_class_numbers = {}
for letter in ("n", "e", "a"):
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / letter)
conn = sqlite3.connect(db_path)
try:
numbers = [
row[0].split(aa.FIELD_SEP, 1)[0]
for row in conn.execute("select flds from notes")
]
finally:
conn.close()
per_class_numbers[letter.upper()] = sorted(numbers)
self.assertEqual(
per_class_numbers,
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
)
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-n.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, 1)
self.assertEqual(card_count, 1)
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 &lt; ", rendered)
self.assertIn(r"\(P = U \cdot I\)", rendered)
self.assertIn(r"\(a &lt; b\)", rendered)
self.assertIn(" &amp; ", rendered)
self.assertNotIn("$P =", rendered)
def test_catalog_underline_markup_is_preserved_safely(self):
rendered = aa.text_html("foo <u>nicht</u> 2 < 3 <script>x</script>")
self.assertIn("<u>nicht</u>", rendered)
self.assertIn("2 &lt; 3", rendered)
self.assertIn("&lt;script&gt;x&lt;/script&gt;", rendered)
def test_underline_inside_math_is_escaped_not_rendered_as_html(self):
rendered = aa.text_html("$<u>x</u>$")
self.assertEqual(rendered, r"\(&lt;u&gt;x&lt;/u&gt;\)")
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("<svg>a</svg>", encoding="utf-8")
(media_dir / "TEST123_b.svg").write_text("<svg>b</svg>", 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 = '<svg width="10" height="10"><path d="M0 0"/></svg>'
once = aa.svg_with_white_background(svg)
twice = aa.svg_with_white_background(once)
self.assertEqual(once, twice)
self.assertIn('<rect data-af-white-background="1"', once)
self.assertLess(once.index("<rect"), once.index("<path"))
def test_main_returns_success(self):
rc = aa.main(["--data", str(self.data_dir), "--out", str(self.out_dir)])
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"])
for first in sorted(out_a.glob("*.apkg")):
second = out_b / first.name
self.assertEqual(first.read_bytes(), second.read_bytes(), first.name)
if __name__ == "__main__":
unittest.main()