Add anki deck generator
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
"""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("<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()
|
||||
Reference in New Issue
Block a user