273 lines
10 KiB
Python
273 lines
10 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),
|
|
],
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"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_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 <u>nicht</u> 2 < 3 <script>x</script>")
|
|
self.assertIn("<u>nicht</u>", 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("$<u>x</u>$")
|
|
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("<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()
|