Files
Amateurfunk-Anki/test_amateurfunk_anki.py
T
2026-06-24 13:46:39 +02:00

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 &lt; ", rendered)
self.assertIn(r"\(P = U \cdot I\)", rendered)
self.assertIn(r"\(a &lt; b\)", rendered)
self.assertIn(" &amp; ", 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 &lt; 3", rendered)
self.assertIn("&lt;script&gt;x&lt;/script&gt;", rendered)
def test_underline_inside_math_is_escaped_not_rendered_as_html(self):
rendered = aa.text_html("$<u>x</u>$")
self.assertEqual(rendered, r"\(&lt;u&gt;x&lt;/u&gt;\)")
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()