Pull explanations from 50ohm.de when available

This commit is contained in:
2026-06-24 13:09:52 +02:00
parent 4a14bbb989
commit 3179f54681
4 changed files with 932 additions and 527 deletions
+114
View File
@@ -420,6 +420,50 @@ class TestAnkiBuild(unittest.TestCase):
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"
@@ -501,6 +545,48 @@ class TestAnkiBuild(unittest.TestCase):
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({
@@ -617,6 +703,34 @@ class TestRealExplanationsFile(unittest.TestCase):
)
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()