Add glossary decks
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""Tests for amateurfunk_technical.
|
||||
|
||||
Like the other deck tests, these inspect the generated `.apkg` directly
|
||||
(ZIP entries plus the SQLite collection) and never require Anki. They
|
||||
also validate the repo's real `technical.json` against the schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import amateurfunk_technical as tech
|
||||
from amateurfunk_anki import FIELD_SEP
|
||||
|
||||
|
||||
def make_technical():
|
||||
return {
|
||||
"terms": [
|
||||
{
|
||||
"code": "SSB",
|
||||
"category": "Betriebsart",
|
||||
"meaning": "Single sideband.",
|
||||
"exam": True,
|
||||
"explanation": "One sideband, carrier suppressed.",
|
||||
},
|
||||
{
|
||||
"code": "NF",
|
||||
"category": "Signal & Frequenz",
|
||||
"meaning": "Niederfrequenz — audio frequency.",
|
||||
"exam": True,
|
||||
},
|
||||
{
|
||||
"code": "SDR",
|
||||
"category": "Digital & Daten",
|
||||
"meaning": "Software defined radio.",
|
||||
"tags": ["modern"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_technical(root: Path, data=None):
|
||||
path = root / "technical.json"
|
||||
path.write_text(
|
||||
json.dumps(data if data is not None else make_technical()),
|
||||
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 TestTechnicalBuild(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.path = write_technical(self.root)
|
||||
self.out_dir = self.root / "anki"
|
||||
|
||||
def _build(self, **kwargs):
|
||||
return tech.build_deck(self.path, self.out_dir, self.root / "data", **kwargs)
|
||||
|
||||
def _collection(self):
|
||||
apkg = next(self.out_dir.glob("*.apkg"))
|
||||
return extract_collection(apkg, self.root)
|
||||
|
||||
def test_one_note_two_cards_per_term(self):
|
||||
result = self._build()
|
||||
self.assertEqual(result["terms"], 3)
|
||||
self.assertEqual(result["notes"], 3)
|
||||
self.assertEqual(result["cards"], 6)
|
||||
|
||||
def test_deck_and_model_are_distinct_from_operating_deck(self):
|
||||
self._build()
|
||||
db_path, _media, _names = self._collection()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
|
||||
models = json.loads(conn.execute("select models from col").fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertIn(tech.DECK_NAME, [d["name"] for d in decks.values()])
|
||||
self.assertEqual(
|
||||
next(iter(models.values()))["name"], tech.MODEL_NAME
|
||||
)
|
||||
|
||||
def test_category_becomes_kind_field_and_tag(self):
|
||||
self._build()
|
||||
db_path, _media, _names = self._collection()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
rows = dict(
|
||||
(r[0], (r[1], r[2]))
|
||||
for r in conn.execute("select sfld, flds, tags from notes")
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
flds, tags = rows["SSB"]
|
||||
# Kind field (index 2) carries the German category.
|
||||
self.assertEqual(flds.split(FIELD_SEP)[2], "Betriebsart")
|
||||
self.assertIn(" technik ", tags)
|
||||
self.assertIn(" kategorie-betriebsart ", tags)
|
||||
self.assertIn(" pruefung ", tags)
|
||||
# A non-exam term carries no pruefung tag but keeps extra tags.
|
||||
_flds, sdr_tags = rows["SDR"]
|
||||
self.assertNotIn("pruefung", sdr_tags)
|
||||
self.assertIn(" modern ", sdr_tags)
|
||||
|
||||
def test_each_note_has_forward_and_reverse_card(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"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
ords_for_ssb = sorted(o for s, o in rows if s == "SSB")
|
||||
self.assertEqual(ords_for_ssb, [0, 1])
|
||||
|
||||
def test_no_media_in_package(self):
|
||||
self._build()
|
||||
_db, media, names = self._collection()
|
||||
self.assertEqual(media, {})
|
||||
self.assertEqual(names, {"collection.anki2", "media"})
|
||||
|
||||
def test_build_is_byte_deterministic_for_same_input(self):
|
||||
out_a = self.root / "a"
|
||||
out_b = self.root / "b"
|
||||
tech.build_deck(self.path, out_a, self.root / "data", override_epoch=99)
|
||||
tech.build_deck(self.path, out_b, self.root / "data", override_epoch=99)
|
||||
for first in sorted(out_a.glob("*.apkg")):
|
||||
self.assertEqual(
|
||||
first.read_bytes(), (out_b / first.name).read_bytes(), first.name
|
||||
)
|
||||
|
||||
def test_ids_are_namespaced_apart_from_operating_deck(self):
|
||||
# A code that exists in both decks (e.g. a hypothetical clash)
|
||||
# must not share a GUID across the two namespaces.
|
||||
from amateurfunk_shorthand import make_note as sh_make_note
|
||||
op = sh_make_note("SSB", "x", "k", "", None, None, " t ", "shorthand")
|
||||
te = tech.build_notes(
|
||||
[{"code": "SSB", "category": "Betriebsart", "meaning": "x"}]
|
||||
)[0]
|
||||
self.assertNotEqual(op["guid"], te["guid"])
|
||||
self.assertNotEqual(op["note_id"], te["note_id"])
|
||||
|
||||
def test_duplicate_code_is_rejected(self):
|
||||
data = make_technical()
|
||||
data["terms"].append(
|
||||
{"code": "SSB", "category": "Betriebsart", "meaning": "dup"}
|
||||
)
|
||||
path = write_technical(self.root, data)
|
||||
with self.assertRaises(tech.AnkiBuildError) as ctx:
|
||||
tech.build_deck(path, self.out_dir, self.root / "data")
|
||||
self.assertIn("SSB", str(ctx.exception))
|
||||
|
||||
def test_missing_category_is_rejected(self):
|
||||
data = make_technical()
|
||||
del data["terms"][0]["category"]
|
||||
path = write_technical(self.root, data)
|
||||
with self.assertRaises(tech.AnkiBuildError) as ctx:
|
||||
tech.build_deck(path, self.out_dir, self.root / "data")
|
||||
self.assertIn("category", str(ctx.exception))
|
||||
|
||||
def test_unknown_field_is_rejected(self):
|
||||
data = make_technical()
|
||||
data["terms"][0]["author"] = "claude"
|
||||
path = write_technical(self.root, data)
|
||||
with self.assertRaises(tech.AnkiBuildError) as ctx:
|
||||
tech.build_deck(path, self.out_dir, self.root / "data")
|
||||
self.assertIn("author", str(ctx.exception))
|
||||
|
||||
def test_missing_file_is_a_hard_error(self):
|
||||
with self.assertRaises(tech.AnkiBuildError):
|
||||
tech.build_deck(self.root / "nope.json", self.out_dir, self.root / "data")
|
||||
|
||||
def test_main_returns_success(self):
|
||||
rc = tech.main([
|
||||
"--technical", str(self.path),
|
||||
"--out", str(self.out_dir),
|
||||
"--data", str(self.root / "data"),
|
||||
])
|
||||
self.assertEqual(rc, tech.EXIT_OK)
|
||||
|
||||
|
||||
class TestRealTechnicalFile(unittest.TestCase):
|
||||
"""Validate the repo's actual technical.json against the schema."""
|
||||
|
||||
def test_repo_technical_builds(self):
|
||||
repo = Path(__file__).resolve().parent
|
||||
path = repo / "technical.json"
|
||||
if not path.exists():
|
||||
self.skipTest("technical.json not available")
|
||||
terms = tech.load_technical(path)
|
||||
notes = tech.build_notes(terms)
|
||||
self.assertEqual(len(notes), len(terms))
|
||||
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user