Files
Amateurfunk-Anki/test_amateurfunk_shorthand.py
T
2026-06-17 11:48:27 +02:00

278 lines
10 KiB
Python

"""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("<code>Tnx for QSO.</code>", 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()