893 lines
35 KiB
Python
893 lines
35 KiB
Python
"""Tests for amateurfunk_anki.
|
|
|
|
These tests inspect the generated `.apkg` package structure directly:
|
|
ZIP entries, media map, and the SQLite collection database. They do not
|
|
require Anki itself to be installed.
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import amateurfunk_anki as aa
|
|
|
|
|
|
def make_catalog():
|
|
return {
|
|
"metadata": {
|
|
"edition": "3. Auflage, März 2024",
|
|
"issued_on": "2024-03-20",
|
|
"valid_from": "2024-06-24",
|
|
"license": "DL-DE->BY-2.0",
|
|
},
|
|
"sections": [
|
|
{
|
|
"title": "Prüfungsfragen im Prüfungsteil: Technische Kenntnisse",
|
|
"sections": [
|
|
{
|
|
"title": "Grundlagen",
|
|
"questions": [
|
|
question("NA101", "1", "Was ist A?", "NA101_q"),
|
|
question("EA202", "2", "Was ist E?", None),
|
|
question("AA303", "3", "Was ist A-Klasse?", None),
|
|
],
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"title": "Prüfungsfragen im Prüfungsteil: Betriebliche Kenntnisse",
|
|
"questions": [
|
|
question("BA101", "1", "Betrieb?", "BA101_q"),
|
|
],
|
|
},
|
|
{
|
|
"title": "Prüfungsfragen im Prüfungsteil: Kenntnisse von Vorschriften",
|
|
"questions": [
|
|
question("VA101", "1", "Vorschrift?", None),
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def question(number, klass, prompt, picture):
|
|
data = {
|
|
"number": number,
|
|
"class": klass,
|
|
"question": prompt,
|
|
"answer_a": f"{number} richtig",
|
|
"answer_b": f"{number} falsch B",
|
|
"answer_c": f"{number} falsch C",
|
|
"answer_d": f"{number} falsch D",
|
|
}
|
|
if picture:
|
|
data["picture_question"] = picture
|
|
return data
|
|
|
|
|
|
def make_fetched_data(root: Path):
|
|
data_dir = root / "data"
|
|
edition_dir = data_dir / "2024-03-20-3-auflage"
|
|
media_dir = edition_dir / "svgs"
|
|
media_dir.mkdir(parents=True)
|
|
(media_dir / "NA101_q.svg").write_text("<svg>na</svg>", encoding="utf-8")
|
|
(media_dir / "BA101_q.svg").write_text("<svg>ba</svg>", encoding="utf-8")
|
|
(media_dir / "NA101_a.svg").write_text("<svg>answer-a</svg>", encoding="utf-8")
|
|
|
|
catalog = make_catalog()
|
|
(edition_dir / "fragenkatalog3b.json").write_text(
|
|
json.dumps(catalog, ensure_ascii=False),
|
|
encoding="utf-8",
|
|
)
|
|
(edition_dir / "manifest.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"slug": "2024-03-20-3-auflage",
|
|
"json_filename": "fragenkatalog3b.json",
|
|
"fetched_at": "2026-05-20T12:00:00+00:00",
|
|
"metadata": catalog["metadata"],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(data_dir / "manifest-latest.json").write_text(
|
|
json.dumps({"slug": "2024-03-20-3-auflage"}),
|
|
encoding="utf-8",
|
|
)
|
|
return data_dir
|
|
|
|
|
|
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 TestResolveShuffleSeed(unittest.TestCase):
|
|
"""Unit coverage for the shuffle-seed resolution itself.
|
|
|
|
The CLI-level test patches `resolve_shuffle_seed` out, so the
|
|
actual default-seed behavior is only exercised here.
|
|
"""
|
|
|
|
def test_explicit_seed_is_passed_through(self):
|
|
self.assertEqual(aa.resolve_shuffle_seed("my-seed"), "my-seed")
|
|
|
|
def test_omitted_seed_mints_a_fresh_value_each_call(self):
|
|
first = aa.resolve_shuffle_seed(None)
|
|
second = aa.resolve_shuffle_seed(None)
|
|
self.assertIsInstance(first, str)
|
|
self.assertTrue(first)
|
|
self.assertNotEqual(first, second)
|
|
|
|
|
|
class TestAnkiBuild(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.root = Path(self.tmp.name)
|
|
self.data_dir = make_fetched_data(self.root)
|
|
self.out_dir = self.root / "anki"
|
|
# Hermetic explanations file: empty by default so tests don't
|
|
# pick up the real repo's explanations.json via the CLI
|
|
# default. Individual tests overwrite this file as needed.
|
|
self.explanations_path = self.root / "explanations.json"
|
|
self.explanations_path.write_text("{}", encoding="utf-8")
|
|
|
|
def _build_all(self):
|
|
return aa.build_all(
|
|
self.data_dir,
|
|
self.out_dir,
|
|
seed="test-seed",
|
|
explanations_path=self.explanations_path,
|
|
)
|
|
|
|
def test_builds_one_apkg_per_category(self):
|
|
results = self._build_all()
|
|
paths = sorted(path.name for path in self.out_dir.glob("*.apkg"))
|
|
self.assertEqual(
|
|
paths,
|
|
[
|
|
"amateurfunk-betriebliche-kenntnisse.apkg",
|
|
"amateurfunk-kenntnisse-von-vorschriften.apkg",
|
|
"amateurfunk-technische-kenntnisse-a.apkg",
|
|
"amateurfunk-technische-kenntnisse-e.apkg",
|
|
"amateurfunk-technische-kenntnisse-n.apkg",
|
|
],
|
|
)
|
|
by_deck = {r["deck"]: r["questions"] for r in results}
|
|
self.assertEqual(
|
|
by_deck,
|
|
{
|
|
"Amateurfunk::Technische Kenntnisse::N": 1,
|
|
"Amateurfunk::Technische Kenntnisse::E": 1,
|
|
"Amateurfunk::Technische Kenntnisse::A": 1,
|
|
"Amateurfunk::Betriebliche Kenntnisse": 1,
|
|
"Amateurfunk::Kenntnisse von Vorschriften": 1,
|
|
},
|
|
)
|
|
|
|
def test_technische_decks_partition_strictly_by_class_field(self):
|
|
self._build_all()
|
|
per_class_numbers = {}
|
|
for letter in ("n", "e", "a"):
|
|
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
|
|
db_path, _media, _names = extract_collection(apkg, self.root / letter)
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
numbers = [
|
|
row[0].split(aa.FIELD_SEP, 1)[0]
|
|
for row in conn.execute("select flds from notes")
|
|
]
|
|
finally:
|
|
conn.close()
|
|
per_class_numbers[letter.upper()] = sorted(numbers)
|
|
self.assertEqual(
|
|
per_class_numbers,
|
|
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
|
|
)
|
|
|
|
def test_split_class_packages_ship_topic_subdeck_tree(self):
|
|
# The E and A packages each stay one file but carry a deck tree:
|
|
# the anchoring class deck plus one sub-deck per first-level
|
|
# topic, with each card filed under its topic sub-deck.
|
|
self._build_all()
|
|
for letter in ("e", "a"):
|
|
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
|
|
db_path, _media, _names = extract_collection(apkg, self.root / letter)
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
decks = json.loads(
|
|
conn.execute("select decks from col").fetchone()[0]
|
|
)
|
|
deck_names = {d["name"] for d in decks.values()}
|
|
id_to_name = {int(k): d["name"] for k, d in decks.items()}
|
|
card_decks = sorted(
|
|
id_to_name[row[0]]
|
|
for row in conn.execute("select did from cards")
|
|
)
|
|
finally:
|
|
conn.close()
|
|
root = f"Amateurfunk::Technische Kenntnisse::{letter.upper()}"
|
|
self.assertEqual(deck_names, {root, f"{root}::Grundlagen"})
|
|
self.assertEqual(card_decks, [f"{root}::Grundlagen"])
|
|
|
|
def test_single_topic_classes_ship_one_flat_deck(self):
|
|
# N (and E) are not split: one deck, card filed directly in it.
|
|
self._build_all()
|
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
|
db_path, _media, _names = extract_collection(apkg, self.root / "n")
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
decks = json.loads(
|
|
conn.execute("select decks from col").fetchone()[0]
|
|
)
|
|
finally:
|
|
conn.close()
|
|
self.assertEqual(
|
|
{d["name"] for d in decks.values()},
|
|
{"Amateurfunk::Technische Kenntnisse::N"},
|
|
)
|
|
|
|
def test_apkg_contains_notes_cards_and_media(self):
|
|
self._build_all()
|
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
|
db_path, media, names = extract_collection(apkg, self.root)
|
|
|
|
self.assertIn("collection.anki2", names)
|
|
self.assertIn("media", names)
|
|
self.assertEqual(set(media.values()), {"NA101_q.svg"})
|
|
self.assertIn("0", names)
|
|
with zipfile.ZipFile(apkg) as zf:
|
|
packaged_svg = zf.read("0").decode("utf-8")
|
|
self.assertIn('data-af-white-background="1"', packaged_svg)
|
|
self.assertIn('fill="#fff"', packaged_svg)
|
|
self.assertLess(
|
|
packaged_svg.index('data-af-white-background="1"'),
|
|
packaged_svg.index("na"),
|
|
)
|
|
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
note_count = conn.execute("select count(*) from notes").fetchone()[0]
|
|
card_count = conn.execute("select count(*) from cards").fetchone()[0]
|
|
self.assertEqual(note_count, 1)
|
|
self.assertEqual(card_count, 1)
|
|
fields = [row[0] for row in conn.execute("select flds from notes")]
|
|
finally:
|
|
conn.close()
|
|
|
|
joined = "\n".join(fields)
|
|
self.assertIn('src="NA101_q.svg"', joined)
|
|
self.assertIn("Richtige Antwort:", joined)
|
|
self.assertIn("NA101 richtig", joined)
|
|
self.assertIn("Technische Kenntnisse / Grundlagen", joined)
|
|
self.assertNotIn("Prüfungsfragen im Prüfungsteil", joined)
|
|
|
|
def test_answers_are_shuffled_and_back_reveals_display_label(self):
|
|
item = aa.QuestionItem(
|
|
question=question("TEST123", "1", "Prompt?", None),
|
|
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
|
|
)
|
|
media = aa.MediaRegistry(self.root / "missing")
|
|
front, back, correct_label = aa.render_question(item, media, "seed")
|
|
|
|
self.assertIn("Richtige Antwort:", back)
|
|
self.assertIn(f"Richtige Antwort: {correct_label}", back)
|
|
self.assertIn("TEST123 richtig", back)
|
|
|
|
# The answer texts should not be in their upstream A/B/C/D order.
|
|
positions = [
|
|
front.index("TEST123 richtig"),
|
|
front.index("TEST123 falsch B"),
|
|
front.index("TEST123 falsch C"),
|
|
front.index("TEST123 falsch D"),
|
|
]
|
|
self.assertNotEqual(positions, sorted(positions))
|
|
|
|
def test_latex_is_converted_to_mathjax_and_text_is_escaped(self):
|
|
rendered = aa.text_html("Leistung < $P = U \\cdot I$ & $a < b$")
|
|
self.assertIn("Leistung < ", rendered)
|
|
self.assertIn(r"\(P = U \cdot I\)", rendered)
|
|
self.assertIn(r"\(a < b\)", rendered)
|
|
self.assertIn(" & ", rendered)
|
|
self.assertNotIn("$P =", rendered)
|
|
|
|
def test_catalog_underline_markup_is_preserved_safely(self):
|
|
rendered = aa.text_html("foo <u>nicht</u> 2 < 3 <script>x</script>")
|
|
self.assertIn("<u>nicht</u>", rendered)
|
|
self.assertIn("2 < 3", rendered)
|
|
self.assertIn("<script>x</script>", rendered)
|
|
|
|
def test_underline_inside_math_is_escaped_not_rendered_as_html(self):
|
|
rendered = aa.text_html("$<u>x</u>$")
|
|
self.assertEqual(rendered, r"\(<u>x</u>\)")
|
|
|
|
def test_tags_use_license_letters_and_skip_singleton_number_tag(self):
|
|
item = aa.QuestionItem(
|
|
question=question("NA101", "1", "Prompt?", None),
|
|
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
|
|
)
|
|
tags = aa.tags_for_item(item)
|
|
self.assertIn(" klasse-N ", tags)
|
|
self.assertNotIn(" klasse-1 ", tags)
|
|
self.assertNotIn("nummer-NA101", tags)
|
|
|
|
def test_breadcrumb_strips_verbose_pruefungsteil_prefix(self):
|
|
item = aa.QuestionItem(
|
|
question=question("TEST123", "1", "Prompt?", None),
|
|
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
|
|
)
|
|
front, _back, _label = aa.render_question(
|
|
item,
|
|
aa.MediaRegistry(self.root / "missing"),
|
|
"seed",
|
|
)
|
|
self.assertIn("Technische Kenntnisse / Leaf", front)
|
|
self.assertNotIn("Prüfungsfragen im Prüfungsteil", front)
|
|
|
|
def test_answer_choice_pictures_follow_shuffle(self):
|
|
media_dir = self.root / "media"
|
|
media_dir.mkdir()
|
|
(media_dir / "TEST123_a.svg").write_text("<svg>a</svg>", encoding="utf-8")
|
|
(media_dir / "TEST123_b.svg").write_text("<svg>b</svg>", encoding="utf-8")
|
|
q = question("TEST123", "1", "Prompt?", None)
|
|
q["picture_a"] = "TEST123_a"
|
|
q["picture_b"] = "TEST123_b"
|
|
item = aa.QuestionItem(
|
|
question=q,
|
|
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
|
|
)
|
|
media = aa.MediaRegistry(media_dir)
|
|
front, back, _label = aa.render_question(item, media, "seed")
|
|
self.assertIn('src="TEST123_a.svg"', front)
|
|
self.assertIn('src="TEST123_b.svg"', front)
|
|
self.assertIn('src="TEST123_a.svg"', back)
|
|
self.assertEqual({p.name for p in media.used_paths}, {"TEST123_a.svg", "TEST123_b.svg"})
|
|
|
|
def test_svg_white_background_injection_is_idempotent(self):
|
|
svg = '<svg width="10" height="10"><path d="M0 0"/></svg>'
|
|
once = aa.svg_with_white_background(svg)
|
|
twice = aa.svg_with_white_background(once)
|
|
|
|
self.assertEqual(once, twice)
|
|
self.assertIn('<rect data-af-white-background="1"', once)
|
|
self.assertLess(once.index("<rect"), once.index("<path"))
|
|
|
|
def test_explanation_is_appended_to_back_when_present(self):
|
|
self.explanations_path.write_text(
|
|
json.dumps({
|
|
"NA101": {
|
|
"revision": 1,
|
|
"explanation": "Doubling the voltage halves the current for fixed power.",
|
|
"source": "https://example.invalid/ohms-law",
|
|
"confidence": 8,
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
results = self._build_all()
|
|
n_result = next(
|
|
r for r in results
|
|
if r["deck"] == "Amateurfunk::Technische Kenntnisse::N"
|
|
)
|
|
self.assertEqual(n_result["explanations"], 1)
|
|
|
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
|
db_path, _media, _names = extract_collection(apkg, self.root / "n")
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
fields = [row[0] for row in conn.execute("select flds from notes")]
|
|
finally:
|
|
conn.close()
|
|
joined = "\n".join(fields)
|
|
self.assertIn("af-explanation", joined)
|
|
self.assertIn("Doubling the voltage halves", joined)
|
|
self.assertIn(
|
|
'<a href="https://example.invalid/ohms-law">'
|
|
'https://example.invalid/ohms-law</a>',
|
|
joined,
|
|
)
|
|
|
|
def test_low_confidence_explanation_shows_badge(self):
|
|
item = aa.QuestionItem(
|
|
question=question("TEST123", "1", "Prompt?", None),
|
|
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
|
|
)
|
|
low = {"TEST123": {
|
|
"revision": 1,
|
|
"explanation": "Weak guess.",
|
|
"source": "TBD",
|
|
"confidence": aa.LOW_CONFIDENCE_THRESHOLD - 1,
|
|
}}
|
|
high = {"TEST123": {
|
|
"revision": 1,
|
|
"explanation": "Solid reasoning.",
|
|
"source": "AFuV §1",
|
|
"confidence": aa.LOW_CONFIDENCE_THRESHOLD,
|
|
}}
|
|
media = aa.MediaRegistry(self.root / "missing")
|
|
_f1, back_low, _l1 = aa.render_question(item, media, "seed", low)
|
|
_f2, back_high, _l2 = aa.render_question(item, media, "seed", high)
|
|
self.assertIn("af-explanation-low-confidence", back_low)
|
|
self.assertIn("low confidence", back_low)
|
|
self.assertNotIn("af-explanation-low-confidence", back_high)
|
|
self.assertNotIn("low confidence", back_high)
|
|
|
|
def test_cc_by_credit_gated_on_provenance_not_source_domain(self):
|
|
"""The CC BY derivative-work credit must follow explicit
|
|
provenance, not the citation domain: an explanation merely
|
|
citing a 50ohm.de page is NOT a derivative work and must not
|
|
carry the credit, while one marked as derived from a worked
|
|
solution must."""
|
|
derived = {
|
|
"revision": 2,
|
|
"explanation": "Worked-solution port.",
|
|
"source": "https://50ohm.de/NEA_x.html#AD106",
|
|
"confidence": 8,
|
|
"provenance": aa.PROVENANCE_50OHM_SOLUTION,
|
|
}
|
|
cited_only = {
|
|
"revision": 1,
|
|
"explanation": "Independently written, just cites a study page.",
|
|
"source": "https://50ohm.de/NEA_x.html#XX101",
|
|
"confidence": 8,
|
|
}
|
|
derived_html = aa.render_explanation(derived)
|
|
cited_html = aa.render_explanation(cited_only)
|
|
self.assertIn("af-explanation-credit", derived_html)
|
|
self.assertIn("CC BY 4.0", derived_html)
|
|
self.assertIn("50ohm.de-Autorenteam", derived_html)
|
|
self.assertNotIn("af-explanation-credit", cited_html)
|
|
self.assertNotIn("translated", cited_html)
|
|
|
|
def test_root_deck_description_carries_attribution(self):
|
|
self._build_all()
|
|
apkg = self.out_dir / "amateurfunk-betriebliche-kenntnisse.apkg"
|
|
db_path, _media, _names = extract_collection(apkg, self.root / "b")
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
|
|
finally:
|
|
conn.close()
|
|
descs = [d["desc"] for d in decks.values() if d["desc"]]
|
|
self.assertEqual(len(descs), 1) # only the root deck carries it
|
|
desc = descs[0]
|
|
self.assertIn("CC BY 4.0", desc)
|
|
self.assertIn("govdata.de/dl-de/by-2-0", desc)
|
|
self.assertIn("50ohm.de-Autorenteam", desc)
|
|
self.assertIn("Bundesnetzagentur", desc)
|
|
|
|
def test_missing_explanation_leaves_card_unchanged(self):
|
|
self._build_all()
|
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
|
|
db_path, _media, _names = extract_collection(apkg, self.root / "e")
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
fields = [row[0] for row in conn.execute("select flds from notes")]
|
|
finally:
|
|
conn.close()
|
|
self.assertNotIn("af-explanation", "\n".join(fields))
|
|
|
|
def test_non_url_source_is_rendered_as_plain_text(self):
|
|
item = aa.QuestionItem(
|
|
question=question("TEST123", "1", "Prompt?", None),
|
|
path=("Prüfungsfragen im Prüfungsteil: Technische Kenntnisse", "Leaf"),
|
|
)
|
|
explanations = {
|
|
"TEST123": {
|
|
"revision": 1,
|
|
"explanation": "Because $P = U \\cdot I$ and the spec fixes P.",
|
|
"source": "AFuV §16(2)",
|
|
"confidence": 9,
|
|
}
|
|
}
|
|
_front, back, _label = aa.render_question(
|
|
item, aa.MediaRegistry(self.root / "missing"), "seed", explanations,
|
|
)
|
|
self.assertIn("AFuV §16(2)", back)
|
|
self.assertNotIn("<a href=", back)
|
|
# Inline LaTeX in the explanation body goes through text_html().
|
|
self.assertIn(r"\(P = U \cdot I\)", back)
|
|
|
|
def test_unknown_explanation_key_fails_the_build(self):
|
|
self.explanations_path.write_text(
|
|
json.dumps({
|
|
"NA10I": { # typo: 'I' instead of '1' — not in the catalog
|
|
"revision": 1,
|
|
"explanation": "ok",
|
|
"source": "ok",
|
|
"confidence": 5,
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaises(aa.AnkiBuildError) as ctx:
|
|
self._build_all()
|
|
self.assertIn("NA10I", str(ctx.exception))
|
|
|
|
def test_schema_rejects_bool_in_integer_fields(self):
|
|
for bad_field in ("revision", "confidence"):
|
|
entry = {
|
|
"revision": 1,
|
|
"explanation": "ok",
|
|
"source": "ok",
|
|
"confidence": 5,
|
|
}
|
|
entry[bad_field] = True
|
|
self.explanations_path.write_text(
|
|
json.dumps({"NA101": entry}),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaises(aa.AnkiBuildError):
|
|
self._build_all()
|
|
|
|
def test_schema_rejects_extra_fields(self):
|
|
self.explanations_path.write_text(
|
|
json.dumps({
|
|
"NA101": {
|
|
"revision": 1,
|
|
"explanation": "ok",
|
|
"source": "ok",
|
|
"confidence": 5,
|
|
"author": "claude",
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaises(aa.AnkiBuildError) as ctx:
|
|
self._build_all()
|
|
self.assertIn("author", str(ctx.exception))
|
|
|
|
def test_schema_accepts_valid_provenance(self):
|
|
path = self.root / "exp_provenance.json"
|
|
path.write_text(
|
|
json.dumps({"NA101": {
|
|
"revision": 1, "explanation": "ok", "source": "ok",
|
|
"confidence": 5, "provenance": aa.PROVENANCE_50OHM_SOLUTION,
|
|
}}),
|
|
encoding="utf-8",
|
|
)
|
|
loaded = aa.load_explanations(path)
|
|
self.assertEqual(
|
|
loaded["NA101"]["provenance"], aa.PROVENANCE_50OHM_SOLUTION,
|
|
)
|
|
|
|
def test_schema_rejects_unknown_provenance_value(self):
|
|
path = self.root / "exp_badprov.json"
|
|
path.write_text(
|
|
json.dumps({"NA101": {
|
|
"revision": 1, "explanation": "ok", "source": "ok",
|
|
"confidence": 5, "provenance": "made-up",
|
|
}}),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaises(aa.AnkiBuildError):
|
|
aa.load_explanations(path)
|
|
|
|
def test_schema_rejects_unhashable_provenance(self):
|
|
# A malformed but valid-JSON provenance value (list/object) is
|
|
# unhashable; the validator must surface AnkiBuildError, never a
|
|
# raw TypeError from the set-membership test.
|
|
for bad in ([], {}):
|
|
path = self.root / "exp_unhashable.json"
|
|
path.write_text(
|
|
json.dumps({"NA101": {
|
|
"revision": 1, "explanation": "ok", "source": "ok",
|
|
"confidence": 5, "provenance": bad,
|
|
}}),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaises(aa.AnkiBuildError):
|
|
aa.load_explanations(path)
|
|
|
|
def test_invalid_explanation_schema_raises_build_error(self):
|
|
self.explanations_path.write_text(
|
|
json.dumps({
|
|
"NA101": {
|
|
"revision": 1,
|
|
"explanation": "ok",
|
|
"source": "ok",
|
|
"confidence": 11, # out of range
|
|
}
|
|
}),
|
|
encoding="utf-8",
|
|
)
|
|
with self.assertRaises(aa.AnkiBuildError):
|
|
self._build_all()
|
|
|
|
def test_missing_explanations_file_is_treated_as_empty(self):
|
|
missing = self.root / "does-not-exist.json"
|
|
results = aa.build_all(
|
|
self.data_dir,
|
|
self.out_dir,
|
|
seed="test-seed",
|
|
explanations_path=missing,
|
|
)
|
|
self.assertTrue(all(r["explanations"] == 0 for r in results))
|
|
|
|
def test_main_returns_success(self):
|
|
rc = aa.main([
|
|
"--data", str(self.data_dir),
|
|
"--out", str(self.out_dir),
|
|
"--explanations", str(self.explanations_path),
|
|
])
|
|
self.assertEqual(rc, aa.EXIT_OK)
|
|
|
|
def test_build_is_byte_deterministic_for_same_input(self):
|
|
out_a = self.root / "anki-a"
|
|
out_b = self.root / "anki-b"
|
|
common = [
|
|
"--data", str(self.data_dir),
|
|
"--explanations", str(self.explanations_path),
|
|
"--seed", "stable-test-seed",
|
|
"--epoch", "1234567890",
|
|
]
|
|
aa.main([*common, "--out", str(out_a)])
|
|
aa.main([*common, "--out", str(out_b)])
|
|
|
|
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_cli_without_seed_reshuffles_each_build(self):
|
|
out_a = self.root / "anki-fresh-a"
|
|
out_b = self.root / "anki-fresh-b"
|
|
common = [
|
|
"--data", str(self.data_dir),
|
|
"--explanations", str(self.explanations_path),
|
|
"--epoch", "1234567890",
|
|
]
|
|
|
|
with patch(
|
|
"amateurfunk_anki.resolve_shuffle_seed",
|
|
side_effect=["fresh-a", "fresh-b"],
|
|
) as resolve_seed:
|
|
aa.main([*common, "--out", str(out_a)])
|
|
aa.main([*common, "--out", str(out_b)])
|
|
|
|
resolved_inputs = [
|
|
call.args[0] for call in resolve_seed.call_args_list
|
|
]
|
|
self.assertEqual(resolved_inputs, [None, None])
|
|
byte_equal = [
|
|
first.read_bytes() == (out_b / first.name).read_bytes()
|
|
for first in sorted(out_a.glob("*.apkg"))
|
|
]
|
|
self.assertFalse(all(byte_equal))
|
|
|
|
|
|
class TestIntrinsicSvgSize(unittest.TestCase):
|
|
"""Parsing of native figure dimensions — SVG only."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.root = Path(self.tmp.name)
|
|
|
|
def _write(self, name, body):
|
|
path = self.root / name
|
|
path.write_text(body, encoding="utf-8")
|
|
return path
|
|
|
|
def test_svg_unitless_width_and_height(self):
|
|
p = self._write("a.svg", '<svg width="115.5" height="120"><g/></svg>')
|
|
self.assertEqual(aa.intrinsic_svg_size(p), (115.5, 120.0))
|
|
|
|
def test_svg_px_suffix_is_tolerated(self):
|
|
p = self._write("a.svg", '<svg width="200px" height="80px"></svg>')
|
|
self.assertEqual(aa.intrinsic_svg_size(p), (200.0, 80.0))
|
|
|
|
def test_svg_percentage_dimension_is_rejected(self):
|
|
p = self._write("a.svg", '<svg width="100%" height="100%"></svg>')
|
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
|
|
|
def test_svg_missing_dimension_returns_none(self):
|
|
p = self._write("a.svg", '<svg width="200"></svg>')
|
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
|
|
|
def test_svg_without_root_tag_returns_none(self):
|
|
p = self._write("a.svg", "not an svg at all")
|
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
|
|
|
def test_svg_leading_decimal_is_accepted(self):
|
|
p = self._write("a.svg", '<svg width=".5" height="10"></svg>')
|
|
self.assertEqual(aa.intrinsic_svg_size(p), (0.5, 10.0))
|
|
|
|
def test_svg_malformed_number_returns_none_not_raises(self):
|
|
# Permissive matching used to let "1.2.3" reach float() and abort
|
|
# the build; now it falls back to None like any other bad value.
|
|
for bad in ('1.2.3', '.', '1.', '1..2'):
|
|
p = self._write("a.svg", f'<svg width="{bad}" height="10"></svg>')
|
|
self.assertIsNone(aa.intrinsic_svg_size(p), bad)
|
|
|
|
def test_non_svg_is_not_sized(self):
|
|
# A PNG with a perfectly valid-looking name is still not sized:
|
|
# we deliberately only enlarge SVGs.
|
|
p = self.root / "a.png"
|
|
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
|
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
|
|
|
|
|
class TestScaleForSize(unittest.TestCase):
|
|
"""The clamped enlargement factor."""
|
|
|
|
def test_small_figure_gets_full_multiplier(self):
|
|
self.assertEqual(aa.scale_for_size(115, 120), aa.MEDIA_SCALE)
|
|
|
|
def test_wide_figure_is_clamped_by_max_width(self):
|
|
self.assertEqual(aa.scale_for_size(aa.MEDIA_MAX_WIDTH, 50), 1.0)
|
|
self.assertAlmostEqual(
|
|
aa.scale_for_size(aa.MEDIA_MAX_WIDTH / 2, 50), 2.0
|
|
)
|
|
# Between half-cap and cap: enlarged only until width hits the cap.
|
|
wide = aa.MEDIA_MAX_WIDTH * 0.75
|
|
self.assertAlmostEqual(
|
|
aa.scale_for_size(wide, 50), aa.MEDIA_MAX_WIDTH / wide
|
|
)
|
|
|
|
def test_tall_figure_is_clamped_by_max_height(self):
|
|
self.assertEqual(aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT), 1.0)
|
|
self.assertAlmostEqual(
|
|
aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT / 2), 2.0
|
|
)
|
|
tall = aa.MEDIA_MAX_HEIGHT * 0.75
|
|
self.assertAlmostEqual(
|
|
aa.scale_for_size(50, tall), aa.MEDIA_MAX_HEIGHT / tall
|
|
)
|
|
|
|
def test_large_catalog_schematic_grows_toward_cap(self):
|
|
# NG205-like (635x460): with the 1024x768 cap it now enlarges,
|
|
# bounded by width — up to 1024 px wide.
|
|
self.assertAlmostEqual(
|
|
aa.scale_for_size(635, 460), aa.MEDIA_MAX_WIDTH / 635
|
|
)
|
|
|
|
def test_never_shrinks_below_native(self):
|
|
self.assertEqual(aa.scale_for_size(5000, 5000), 1.0)
|
|
|
|
def test_degenerate_size_is_native(self):
|
|
self.assertEqual(aa.scale_for_size(0, 0), 1.0)
|
|
|
|
|
|
class TestImageHtmlScaling(unittest.TestCase):
|
|
"""End-to-end `<img>` emission and the per-path size cache."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.media_dir = Path(self.tmp.name) / "svgs"
|
|
self.media_dir.mkdir()
|
|
|
|
def _write(self, name, body):
|
|
(self.media_dir / name).write_text(body, encoding="utf-8")
|
|
|
|
def test_small_figure_emits_doubled_width(self):
|
|
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
|
|
media = aa.MediaRegistry(self.media_dir)
|
|
self.assertIn('width="230"', media.image_html("S_q"))
|
|
|
|
def test_figure_at_cap_emits_no_width(self):
|
|
# Exactly at the cap → scale 1.0 → native size, no width.
|
|
self._write(
|
|
"L_q.svg",
|
|
f'<svg width="{aa.MEDIA_MAX_WIDTH}" '
|
|
f'height="{aa.MEDIA_MAX_HEIGHT}"></svg>',
|
|
)
|
|
media = aa.MediaRegistry(self.media_dir)
|
|
html = media.image_html("L_q")
|
|
self.assertIn('class="af-media"', html)
|
|
self.assertNotIn("width=", html)
|
|
|
|
def test_png_is_not_scaled(self):
|
|
# Only a PNG for this stem (no .svg sibling): emitted at native
|
|
# size with no width, because we never size raster figures.
|
|
(self.media_dir / "P_q.png").write_bytes(
|
|
b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
|
)
|
|
media = aa.MediaRegistry(self.media_dir)
|
|
html = media.image_html("P_q")
|
|
self.assertIn('src="P_q.png"', html)
|
|
self.assertNotIn("width=", html)
|
|
|
|
def test_unreadable_size_falls_back_to_no_width(self):
|
|
self._write("U_q.svg", '<svg width="100%" height="100%"></svg>')
|
|
media = aa.MediaRegistry(self.media_dir)
|
|
self.assertNotIn("width=", media.image_html("U_q"))
|
|
|
|
def test_malformed_number_falls_back_without_raising(self):
|
|
self._write("M_q.svg", '<svg width="1.2.3" height="10"></svg>')
|
|
media = aa.MediaRegistry(self.media_dir)
|
|
html = media.image_html("M_q") # must not raise
|
|
self.assertIn('src="M_q.svg"', html)
|
|
self.assertNotIn("width=", html)
|
|
|
|
def test_size_is_read_once_per_path(self):
|
|
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
|
|
media = aa.MediaRegistry(self.media_dir)
|
|
with patch.object(
|
|
aa, "intrinsic_svg_size", wraps=aa.intrinsic_svg_size
|
|
) as spy:
|
|
media.image_html("S_q")
|
|
media.image_html("S_q")
|
|
self.assertEqual(spy.call_count, 1)
|
|
|
|
|
|
class TestRealExplanationsFile(unittest.TestCase):
|
|
"""Validate the repo's actual explanations.json against the live catalog.
|
|
|
|
The other tests are hermetic by design: they exercise the schema
|
|
validator on synthetic fixtures so the validator's own logic is
|
|
tested without coupling to editorial data. That leaves a gap — a
|
|
real malformed entry or stale key in the repo's explanations.json
|
|
would not show up in `python3 -m unittest`. This test closes it
|
|
when the fetched catalog is present, and skips otherwise so an
|
|
unfetched checkout still runs the rest of the suite.
|
|
"""
|
|
|
|
def test_repo_explanations_pass_schema_and_match_catalog(self):
|
|
repo = Path(__file__).resolve().parent
|
|
explanations_path = repo / "explanations.json"
|
|
catalogs = sorted((repo / "data").glob("*/fragenkatalog*.json"))
|
|
if not explanations_path.exists() or not catalogs:
|
|
self.skipTest("explanations.json or fetched catalog not available")
|
|
explanations = aa.load_explanations(explanations_path)
|
|
catalog = json.loads(catalogs[-1].read_text("utf-8"))
|
|
categories = aa.collect_categories(catalog)
|
|
aa._check_explanation_keys_against_catalog(explanations, categories)
|
|
|
|
def test_repo_explanations_do_not_source_the_question_catalog(self):
|
|
repo = Path(__file__).resolve().parent
|
|
explanations_path = repo / "explanations.json"
|
|
if not explanations_path.exists():
|
|
self.skipTest("explanations.json not available")
|
|
explanations = aa.load_explanations(explanations_path)
|
|
forbidden = [
|
|
"Fragenkatalog/Pruefungsfragen",
|
|
"Fragenkatalog/BetriebVorschrift",
|
|
]
|
|
offenders = sorted(
|
|
key
|
|
for key, value in explanations.items()
|
|
if any(token in value["source"] for token in forbidden)
|
|
)
|
|
self.assertEqual([], offenders)
|
|
|
|
def test_safety_distance_block_uses_transmitter_power_not_erp(self):
|
|
"""AK106-AK112 must feed the EIRP formula with transmitter power.
|
|
|
|
The relation is `P_EIRP = P_S · 10^((g_d+2.15-a)/10)` with `P_S`
|
|
the transmitter (Sender) power; writing `P_EIRP = P_ERP · 10^…`
|
|
double-counts the antenna gain (ERP already includes it). The
|
|
upstream 50ohm solutions carry exactly this mislabel, so guard
|
|
the whole block against it regressing back in.
|
|
"""
|
|
repo = Path(__file__).resolve().parent
|
|
explanations_path = repo / "explanations.json"
|
|
if not explanations_path.exists():
|
|
self.skipTest("explanations.json not available")
|
|
explanations = aa.load_explanations(explanations_path)
|
|
block = [f"AK1{n:02d}" for n in range(6, 13)] # AK106..AK112
|
|
for key in block:
|
|
if key not in explanations:
|
|
continue
|
|
body = explanations[key]["explanation"]
|
|
self.assertIn(
|
|
"P_S", body,
|
|
f"{key}: EIRP should be computed from transmitter power P_S",
|
|
)
|
|
self.assertNotIn(
|
|
r"P_\mathrm{EIRP} = P_\mathrm{ERP}", body,
|
|
f"{key}: EIRP must not be derived by scaling ERP by the gain",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|