214 lines
7.6 KiB
Python
214 lines
7.6 KiB
Python
"""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()
|