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