"""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 B, E, H", gen(2) == 0 and gen(5) == 1 and gen(8) == 2) check("gen_of: deep bands K, N, Q", gen(11) == 3 and gen(14) == 4 and gen(17) == 5) # --- 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 # FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation) dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json") _json.dump({"resolutions": [ # Decision only for Luna from ZdkC, NOT Luna from Black Forest {"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020", "decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"}, ]}, open(dec_c3, "w", encoding="utf-8")) merged_c3 = [ {"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}}, {"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}}, ] conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}] n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3) check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1) check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False) check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True) check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf") try: os.remove(dec_c3) 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-2 MERGE: specific allele must survive the merge regardless of which variant comes first. # dedup() picks the most specific genotype (fewest '?' alleles); C- vs CC -> CC must win. def _minimal_animal(name, dob, mapped): """Build a minimal raw animal dict suitable for dedup().""" from genotype import parse as gparse raw = " ".join(f"{l}{''.join(a)}" for l, pa in mapped.items() for a in [pa]) return { "name": name, "dob": dob, "death": "", "gender": None, "farbschlag": "", "breeder": "", "zucht": "", "parentRefs": [], "photos": [], "sourceFiles": ["test.xlsx"], "tags": [], "deaf": None, "conflict": False, "genotype": {"mapped8locus": mapped, "rawGenotype": raw, "unmappedTokens": []}, "_gen": 0, "_col": 5, "_row": 10, "_file": "test.xlsx", "_zucht": "", } # Order A: C- first, CC second animals_merge_a = [ _minimal_animal("TestTier", "01.01.2020", {"C": ["C", "?"]}), # C- _minimal_animal("TestTier", "01.01.2020", {"C": ["C", "C"]}), # CC ] merged_ma, _, _, _ = e.dedup(animals_merge_a) check("FIX-2 merge A (C- first): result has CC not C-", merged_ma[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"]) # Order B: CC first, C- second (must give same result) animals_merge_b = [ _minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "C"]}), # CC _minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "?"]}), # C- ] merged_mb, _, _, _ = e.dedup(animals_merge_b) check("FIX-2 merge B (CC first): result has CC not C-", merged_mb[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"]) # G- vs Gg: Gg must win animals_merge_g = [ _minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "?"]}), # G- _minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "g"]}), # Gg ] merged_mg, _, _, _ = e.dedup(animals_merge_g) check("FIX-2 merge G (G- vs Gg): Gg wins", merged_mg[0]["genotype"]["mapped8locus"].get("G") == ["G", "g"]) # --- 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 == "") # --- FIX (ab8fdb0b): leading death-marker right after DOB must not leak into geno --- # bare "/+," (Mamono) — death without date _, _, geno_m = e.parse_detail("Mamono,*02.08.2020/+, Aa C- D- Ee gg PP spsp") check("FIX-ab8fdb0b: bare '/+,' stripped from geno (Mamono)", geno_m == "Aa C- D- Ee gg PP spsp") # "/+'" (Talula) — text-month death marker _, _, geno_t = e.parse_detail("Talula,*01.01.2015/+April'2017, aa C- DD ee GG PP spsp") check("FIX-ab8fdb0b: '/+Textmonth'year' stripped from geno (Talula)", geno_t == "aa C- DD ee GG PP spsp") # leading "+" (Fegur) — death date captured, geno clean dob_f, death_f, geno_f = e.parse_detail("Fegur,*01.01.2016+09.07.2017, aa CC DD ee GG PP spsp") check("FIX-ab8fdb0b: leading '+date' stripped from geno (Fegur)", geno_f == "aa CC DD ee GG PP spsp") check("FIX-ab8fdb0b: leading '+date' still captured as death (Fegur)", death_f == "09.07.2017") # "/+ ," (Flippi) — death marker with trailing comma, no date _, _, geno_fl = e.parse_detail("Flippi,*01.01.2016/+ , aa CC DD ee GG PP spsp") check("FIX-ab8fdb0b: '/+ ,' stripped from geno (Flippi)", geno_fl == "aa CC DD ee GG PP spsp") # --- 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")) # --- CR-10: malformed decision genotype must NOT blank the existing genotype --- dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json") _json.dump({"resolutions": [ # Valid decision (genotype parses OK) -> should be applied {"name": "Agouti OK", "dob": "01.01.2020", "decision": "test", "genotype": "aa CC DD ee GG PP spsp rere", "source": "test"}, # Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved {"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test", "genotype": "BLÖDSINN!!!", "source": "test"}, ]}, open(dec_cr10, "w", encoding="utf-8")) merged_cr10 = [ {"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}}, {"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "", "genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}}, ] conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}] n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10) check("CR-10: valid decision genotype is applied (A-locus updated)", merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"]) check("CR-10: malformed decision genotype NOT applied (C-locus preserved)", merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"]) check("CR-10: malformed decision still un-quarantines the animal", merged_cr10[1].get("conflict") is False) check("CR-10: malformed decision adds a decisionWarning", bool(merged_cr10[1].get("decisionWarnings"))) check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2) try: os.remove(dec_cr10) except OSError: pass # --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' --- # Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen. # The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so # "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b. check("KC-matcher: 'Zucht der Kleinen Chaoten'", e.is_clan_zucht("Zucht der Kleinen Chaoten")) check("KC-matcher: 'kleinen Chaoten' (no prefix)", e.is_clan_zucht("kleinen Chaoten")) check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)", e.is_clan_zucht("v.d. Kleinen Chaoten")) check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)", e.is_clan_zucht("ZdkC")) check("KC-matcher: 'von den Kleinen Chaoten' (full long form)", e.is_clan_zucht("von den Kleinen Chaoten")) check("KC-matcher: empty string -> NOT clan", not e.is_clan_zucht("")) check("KC-matcher: 'Black Forest' -> NOT clan", not e.is_clan_zucht("Black Forest")) check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)", e.norm_zucht("Kleine Chaoten") == "kleinechaote") check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'", e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote") # Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record # because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.: check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)", e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote") # --- Stammbaum von Danako validation --- danako_path = r"C:\Users\gulum\dev\Wurfchronik_Bilder\Stammbaum von Danako.xlsx" if not os.path.exists(danako_path): danako_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Danako.xlsx" if os.path.exists(danako_path): print(f"\nFound Danako stammbaum at {danako_path}, running integration validation...") danako_animals = e.extract_stammbaum(danako_path) danako_by_name = {a["name"]: a for a in danako_animals} check("Danako present in Danako sheet", "Danako" in danako_by_name) if "Danako" in danako_by_name: d = danako_by_name["Danako"] check("Danako DOB is 22.08.2018", d["dob"] == "22.08.2018") check("Danako photo matches image7.png", d["photos"] == ["photos/danako-22082018/image7.png"]) check("Osamu present in Danako sheet", "Osamu" in danako_by_name) if "Osamu" in danako_by_name: o = danako_by_name["Osamu"] check("Osamu DOB is 10.12.2015", o["dob"] == "10.12.2015") check("Osamu photo matches image4.jpeg", o["photos"] == ["photos/osamu-10122015/image4.jpeg"]) # Check parentRefs of Osamu in Danako sheet o_parents = o.get("parentRefs", []) o_father = next((p for p in o_parents if p.get("roleGuess") == "father"), None) o_mother = next((p for p in o_parents if p.get("roleGuess") == "mother"), None) check("Osamu father is Porter", o_father is not None and o_father["name"] == "Porter") check("Osamu mother is Yuka", o_mother is not None and o_mother["name"] == "Yuka") if o_father: check("Osamu father DOB is 23.05.2015", o_father["dob"] == "23.05.2015") if o_mother: check("Osamu mother DOB is 12.07.2015", o_mother["dob"] == "12.07.2015") check("Porter present in Danako sheet", "Porter" in danako_by_name) if "Porter" in danako_by_name: p = danako_by_name["Porter"] check("Porter DOB is 23.05.2015", p["dob"] == "23.05.2015") check("Porter photo matches image6.jpeg", p["photos"] == ["photos/porter-23052015/image6.jpeg"]) check("Yuka present in Danako sheet", "Yuka" in danako_by_name) if "Yuka" in danako_by_name: y = danako_by_name["Yuka"] check("Yuka DOB is 12.07.2015", y["dob"] == "12.07.2015") check("Yuka photo matches image5.jpeg", y["photos"] == ["photos/yuka-12072015/image5.jpeg"]) check("Eddward present in Danako sheet", "Eddward" in danako_by_name) if "Eddward" in danako_by_name: ed = danako_by_name["Eddward"] check("Eddward DOB is 18.11.2015", ed["dob"] == "18.11.2015") check("Eddward photo matches image1.jpeg", ed["photos"] == ["photos/eddward-18112015/image1.jpeg"]) check("Harumi present in Danako sheet", "Harumi" in danako_by_name) if "Harumi" in danako_by_name: h = danako_by_name["Harumi"] check("Harumi DOB is 21.02.2015", h["dob"] == "21.02.2015") check("Harumi photo matches image2.jpeg", h["photos"] == ["photos/harumi-21022015/image2.jpeg"]) else: print("\nWarning: Danako stammbaum file not found, skipping integration checks.") # --- Stammbaum von Kazuya: photo generation-shift regression (PHOTO-LEFT-STYLE) --- # This sheet has neither col-1 nor col-4 image anchors, so the old fixed-column # heuristic misread it as right-style and shifted every photo one generation # toward the proband: Kazuya wore his father's (Wilbur's) photo, Wilbur wore the # grandfather's (Elay's). The fix picks the layout that places photos closest to # their animal's name column → photos land on the correct generation. kazuya_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Kazuya.xlsx" if os.path.exists(kazuya_path): print(f"\nFound Kazuya stammbaum, running photo generation-shift validation...") kz = {a["name"]: a for a in e.extract_stammbaum(kazuya_path)} if "Kazuya" in kz: check("Kazuya (proband) has NO photo of his own", kz["Kazuya"]["photos"] == []) if "Wilbur" in kz: check("Wilbur (father) gets his own photo (image7), not the grandfather's", kz["Wilbur"]["photos"] == ["photos/wilbur-19032017/image7.jpeg"]) if "Elay" in kz: check("Elay (grandfather) gets his own photo (image2)", kz["Elay"]["photos"] == ["photos/elay-16032016/image2.jpeg"]) else: print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.") # --- External-origin founders get NO fabricated chart-position parents ------- # Tickets #5 (Bill von Privat), #13 (Cooky vom Zooladen), #28 (Zadar from … Croatia): # pet-shop / private / foreign-import animals have genuinely unknown ancestry, so # _reconstruct_parents must not invent parents for them from neighbouring blocks. check("is_external_origin: 'von Privat' name", e.is_external_origin("Bill von Privat")) check("is_external_origin: 'vom Zooladen (OBI)' name", e.is_external_origin("Cooky vom Zooladen (OBI)")) check("is_external_origin: foreign 'from …, Croatia'", e.is_external_origin("Zadar from Zeko i ptica, Croatia")) check("is_external_origin: clan animal is NOT external", not e.is_external_origin("Silver von den kleinen Chaoten", "Kleine Chaoten")) check("is_external_origin: ordinary cattery 'of Black Forest' is NOT external", not e.is_external_origin("Hagrid Rubeus of Black Forest", "Black Forest")) # _reconstruct_parents must skip the external leaf but still parent the clan child. _ext_animals = [ {"name": "Kind von den Kleinen Chaoten", "_gen": 0, "_row": 5, "dob": "01.01.2020", "zucht": "Kleine Chaoten", "breeder": "", "gender": None, "parentRefs": []}, {"name": "Cooky vom Zooladen (OBI)", "_gen": 1, "_row": 4, "dob": "01.01.2018", "zucht": "", "breeder": "", "gender": "female", "parentRefs": []}, {"name": "Papa von den Kleinen Chaoten", "_gen": 1, "_row": 6, "dob": "01.01.2017", "zucht": "Kleine Chaoten", "breeder": "", "gender": "male", "parentRefs": []}, {"name": "OmaUnbekannt", "_gen": 2, "_row": 3, "dob": "", "zucht": "", "breeder": "", "gender": None, "parentRefs": []}, ] e._reconstruct_parents(_ext_animals) _cooky = next(a for a in _ext_animals if a["name"].startswith("Cooky")) check("_reconstruct_parents: external Cooky gets NO parentRefs", _cooky["parentRefs"] == []) _kind = next(a for a in _ext_animals if a["name"].startswith("Kind")) check("_reconstruct_parents: clan child still gets its chart-position parents", len(_kind["parentRefs"]) >= 1) # --- gender override (conflict-decisions) — Mozart misread male, should be female --- _g_merged = [{"id": "moz", "name": "Mozart of Lennylengo", "dob": "12.03.2017", "gender": "male", "genotype": e.gt.parse(""), "parentRefs": [], "conflict": False}] _g_dec = os.path.join(tempfile.gettempdir(), "conflict-gender-test.json") import json as _json _json.dump({"resolutions": [ {"name": "Mozart of Lennylengo", "dob": "12.03.2017", "gender": "female", "decision": "box colour misread"} ]}, open(_g_dec, "w", encoding="utf-8")) e.apply_conflict_decisions(_g_merged, [], _g_dec) check("gender override: Mozart flipped male -> female", _g_merged[0]["gender"] == "female") try: os.remove(_g_dec) except OSError: pass # --- EMF/WMF-Vorschauen sind keine Fotos (neue Stammbäume 2026-08-18) --- # Excel legt neben dem echten Foto teils ein Vektor-Metafile ab; Browser können es nicht # darstellen → es landete als kaputte Bildkachel in der Tier-Akte. _emf_chart = os.path.join(r"C:\Users\gulum\dev\Sttammbäume", "Stammbaum von Kohlief und Johnny Jumper Kids.xlsx") if os.path.exists(_emf_chart): _emf_animals = e.extract_stammbaum(_emf_chart) _emf_photos = [ph for a in _emf_animals for ph in a["photos"] if ph.lower().endswith((".emf", ".wmf"))] check("EMF/WMF werden nicht als Foto angehängt", _emf_photos == []) else: print("\nWarning: EMF-Stammbaum nicht gefunden, EMF-Check übersprungen.") # --- sternloses Geburtsdatum im Block (Ticket 65266679 Bijou) --- # Einige Charts vergessen den Stern ("22.08.2019/+09.04.2024"); ohne Stern erkannte die # Blockheuristik das Tier gar nicht und _reconstruct_parents griff in den Nachbar-Ast. # Normalisiert wird NUR mit Block-Kontext (Genotyp darunter UND Name darueber), damit # beliebige Datumszellen nicht faelschlich zu Tieren werden. _tmp_star = os.path.join(tempfile.gettempdir(), "starless_dob.xlsx") _make_xlsx(_tmp_star, { # echter Block, aber ohne Stern vor dem Geburtsdatum (Quelldatei-Tippfehler) "K44": "Louis of Black Forest", "K45": "22.08.2019/+09.04.2024", "K46": "Aa CC dd EE Gg P- spsp", # Datum mit Name darueber, aber OHNE Genotyp darunter -> kein Block "N44": "Irgendeine Notiz", "N45": "01.01.2020", # Datum mit Genotyp darunter, aber OHNE Namenszelle darueber -> kein Block "Q45": "02.02.2021", "Q46": "aa CC DD EE GG PP spsp", }) try: _star_animals = e.extract_stammbaum(_tmp_star) _star_by_name = {a["name"]: a for a in _star_animals} check("sternloses DOB wird als Block erkannt", "Louis of Black Forest" in _star_by_name) check("sternloses DOB: Geburts- und Todesdatum geparst", _star_by_name.get("Louis of Black Forest", {}).get("dob") == "22.08.2019" and _star_by_name.get("Louis of Black Forest", {}).get("death") == "09.04.2024") check("Datumszelle ohne Block-Kontext wird NICHT zum Tier", len(_star_animals) == 1) finally: try: os.remove(_tmp_star) except OSError: pass # Direkt auf der Normalisierung: Todesdatum-only-Zellen ("/+01.09.2024", kommt in # "Stammbaum von Kentucky/Watarus Kids" vor) duerfen NIE zum Geburtsdatum werden. _cells = { (11, 45): "22.08.2019/+09.04.2024", # Block -> bekommt den Stern (11, 44): "Louis of Black Forest", (11, 46): "Aa CC dd EE Gg P- spsp", (14, 66): "/+01.09.2024", # nur Todesdatum -> unangetastet (14, 65): "Nani of Black Forest", (14, 67): "Aa CC dd EE Gg P- spsp", (17, 30): "30.01.2020/+", # Block, Todesdatum leer -> bekommt den Stern (17, 29): "Jiminy of Black Forest", (17, 31): "Aa Cc[chm] Dd ee gg P- Spsp", } e._normalise_starless_dobs(_cells) check("Normalisierung setzt den Stern im Block", _cells[(11, 45)] == "*22.08.2019/+09.04.2024") check("Normalisierung setzt den Stern auch bei leerem Todesdatum", _cells[(17, 30)] == "*30.01.2020/+") check("Normalisierung laesst reine Todesdatum-Zellen unberuehrt", _cells[(14, 66)] == "/+01.09.2024") if failed: print(f"\n{failed} test(s) FAILED") sys.exit(1) print("\nALL PASS")