278 lines
10 KiB
Python
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()
|