"""Zero-dep tests for extract.py band-aware Farbschlag + name-bleed guard. Run: python test_extract.py (exit 0 = all pass) Covers (PEDIGREE-LINK / Julian-confirmed): deep pedigree bands (gen >= 2, cols K/N/Q...) are Name/DOB/Genotype ONLY — no Farbschlag cell — so a stray health note or the next block's name must NOT be captured as Farbschlag; early bands (gen 0-1) keep their real Farbschlag. Plus the looks_like_animal_name guard (a parent name must not be a Farbschlag). """ import os import sys import zipfile import tempfile import extract as e failed = 0 def check(name, cond): global failed print(("ok: " if cond else "FAIL: ") + name) if not cond: failed += 1 def _cell(ref, text): return f'{text}' def _make_xlsx(path, cells): """cells: {(colLetter+row): text}. Build a minimal single-sheet xlsx (no styles).""" rows = {} for ref, text in cells.items(): r = int("".join(ch for ch in ref if ch.isdigit())) rows.setdefault(r, []).append(_cell(ref, text)) body = "".join(f'{"".join(cs)}' for r, cs in sorted(rows.items())) sheet = ('' + body + "") with zipfile.ZipFile(path, "w") as z: z.writestr("xl/worksheets/sheet1.xml", sheet) # --- band-aware Farbschlag --- # col E = gen 0 (early, HAS Farbschlag); col K = col 11 = gen 2 (deep, NO Farbschlag). tmp = os.path.join(tempfile.gettempdir(), "bandtest.xlsx") _make_xlsx(tmp, { # early band (E): Name / *DOB / Farbschlag / Genotype "E10": "Chesnut", "E11": "*13.11.2019", "E12": "Kohlfuchsschimmel", "E13": "aa CC DD ee GG PP spsp rere", # deep band (K): Name / *DOB / Genotype / stray NOTE (must NOT become Farbschlag) "K10": "DeepAnimal", "K11": "*01.01.2020", "K12": "aa CC DD EE GG PP spsp rere", "K13": "DD-Tumor", }) try: animals = e.extract_stammbaum(tmp) by_name = {a["name"]: a for a in animals} check("early band keeps real Farbschlag", by_name.get("Chesnut", {}).get("farbschlag") == "Kohlfuchsschimmel") check("deep band has NO Farbschlag (note not grabbed)", by_name.get("DeepAnimal", {}).get("farbschlag") == "") check("deep-band animal still parsed (Name/DOB/Genotype)", "DeepAnimal" in by_name and by_name["DeepAnimal"]["dob"].startswith("01.01")) finally: try: os.remove(tmp) except OSError: pass gen = e.gen_of check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2) check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2) # --- conflict-decisions consumption (HUMANQUESTION D / C6) --- dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json") import json as _json _json.dump({"resolutions": [ {"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019", "decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp", "source": "test"}, {"name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017", "decision": "Todesdatum 10.05.2021 (2022 war Tippfehler)", "dateOfDeath": "10.05.2021", "source": "test"}, ]}, open(dec_path, "w", encoding="utf-8")) merged = [ {"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}}, {"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017", "conflict": True, "farbschlag": "", "death": "10.05.2022", "genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}}, ] conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"}, {"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017"}] n = e.apply_conflict_decisions(merged, conflicts, dec_path) check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False) check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True) check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"]) check("decision dateOfDeath is authoritative (D5)", merged[1]["death"] == "10.05.2021") check("decision removes both entries from conflicts list", conflicts == []) check("apply_conflict_decisions returns resolved count", n == 2) check("missing decisions file tolerated (returns 0)", e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0) # FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json") _json.dump({"resolutions": [ {"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den' "dob": "16.01.2023", "decision": "E-locus = ee[f]", "genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"}, ]}, open(dec_vd, "w", encoding="utf-8")) merged_vd = [ {"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.' "dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}}, ] conflicts_vd = [{"id": "vw"}] n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd) check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1) check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False) # Also verify the workaround spelling (v.d. in decision) matches a 'von den' record _json.dump({"resolutions": [ {"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision "dob": "16.01.2023", "decision": "E-locus = ee[f]", "genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"}, ]}, open(dec_vd, "w", encoding="utf-8")) merged_vd2 = [ {"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den' "dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}}, ] conflicts_vd2 = [{"id": "vw2"}] n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd) check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1) try: os.remove(dec_vd) except OSError: pass # --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges --- dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json") _json.dump({"resolutions": [ {"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021", "decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"}, ]}, open(dec2, "w", encoding="utf-8")) raw = [ {"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"}, # the wrong-dob duplicate {"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"}, # canonical {"name": "Other Animal", "dob": "01.01.2020"}, ] rn = e.apply_dob_remaps(raw, dec2) check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021") check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021") check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020") check("apply_dob_remaps returns remap count", rn == 1) check("after remap both Chelsea share one dedup identity (name+dob)", e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"])) check("missing decisions file tolerated for dob remaps (returns 0)", e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0) try: os.remove(dec2) except OSError: pass try: os.remove(dec_path) except OSError: pass # --- "presence wins" + "specific wins" conflict rules (Julian) --- # present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are. # FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict — # the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP). check("spsp present vs locus absent -> no conflict", not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}])) check("ee[f] vs ee ([f] modifier present/absent) -> no conflict", not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}])) # FIX-2: '?' vs specified = specific wins (was: contradiction) check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict", not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}])) check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict", not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}])) check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict", not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}])) check("FIX-2: G- vs Gg (specific wins) -> NOT conflict", not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}])) check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict", not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}])) # Genuine value contradictions (both alleles specified but different) still quarantine check("Ee vs ee (different base allele) -> conflict", e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}])) check("DD vs Dd (both specified, D vs d) -> conflict", e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}])) check("PP vs Pp (both specified) -> conflict", e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}])) check("c[h] vs c[chm] (different modifiers, both specified) -> conflict", not e._alleles_compatible("c^h", "c^chm")) check("identical genotypes -> no conflict", not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}])) # --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured --- dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018") check("FIX-4: '/ +YEAR' artifact stripped from geno tail", geno4 == "aa C- DD ee Gg PP spsp rere") check("FIX-4: death year still captured from full cell text", death4 == "2018") check("FIX-4: DOB still correct", dob4 == "17.04.2016") # Without artifact — must be unchanged dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere") check("FIX-4: no artifact -> geno unchanged", geno5 == "aa C- DD ee Gg PP spsp rere") check("FIX-4: no artifact -> no spurious death", death5 == "") # --- name-bleed guard (a parent name is not a Farbschlag) --- check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten")) check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten")) check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel")) check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell")) if failed: print(f"\n{failed} test(s) FAILED") sys.exit(1) print("\nALL PASS")