"""Zero-dep tests for merge_and_resolve.py litter-dedup & parent-role logic. Run: python test_merge_resolve.py (exit 0 = all pass) Covers the sibling-pairing data fix: - litter_compatible(): same-date / compatible-parent dedup, incl. the dateless guard that stops unrelated nameless stubs from blind-merging. - assign_parent_roles(): gender-correct role assignment, self-pairing removal, no two same-role parents — the fix for the 16 self-pairings / 30 gender-role errors that the old simple swap missed. """ import sys import merge_and_resolve as m def check(name, cond): if not cond: print(f"FAIL: {name}") check.failed += 1 else: print(f"ok: {name}") check.failed = 0 def litter(date, fid=None, mid=None, fname=None, mname=None): return { "Date": date, "FatherId": fid, "MotherId": mid, "_father_name": fname, "_mother_name": mname, } # ── litter_compatible: both sides fully parented ── check( "same date + same parents → compatible", m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "F", "M")), ) check( "same date + different parents → NOT compatible", not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "X", "Y")), ) check( "different date → NOT compatible", not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2019-01-01", "F", "M")), ) # ── asymmetric (one resolved, one not), real date ── check( "asymmetric same real date, names agree → merge", m.litter_compatible( litter("2018-09-22", "F", "M", "Wonderman", "Unique"), litter("2018-09-22", None, None, "Wonderman", "Unique"), ), ) check( "asymmetric same real date, conflicting names → NO merge", not m.litter_compatible( litter("2018-09-22", "F", "M", "Wonderman", "Unique"), litter("2018-09-22", None, None, "Someone", "Else"), ), ) check( "asymmetric real date, parented side nameless stub → merge (no conflict)", m.litter_compatible( litter("2018-09-22", "F", "M", "Wonderman", "Unique"), litter("2018-09-22", None, None, None, None), ), ) # ── dateless guard: the bug that wrongly merged unrelated stubs ── check( "dateless asymmetric, NO name evidence → do NOT merge (was the bug)", not m.litter_compatible( litter(None, "F", "M", "Akina", "Arrow"), litter(None, None, None, None, None), ), ) check( "dateless asymmetric WITH positive name match → merge", m.litter_compatible( litter(None, "F", "M", "Akina", "Arrow"), litter(None, None, None, "Akina", None), ), ) check( "dateless asymmetric, contradicting names → do NOT merge", not m.litter_compatible( litter(None, "F", "M", "Akina", "Arrow"), litter(None, None, None, "Mismatch", None), ), ) # ── neither side parented → never blind-merge ── check( "neither parented, same date → NOT compatible", not m.litter_compatible(litter("2018-09-22"), litter("2018-09-22")), ) # ── assign_parent_roles ── GENDER = {"bock": "male", "bock2": "male", "maus": "female", "maus2": "female", "u": "unknown", "u2": "unknown"} gof = lambda gid: GENDER.get(gid) check("correct roles stay put", m.assign_parent_roles("bock", "maus", gof) == ("bock", "maus")) check("reversed roles get swapped", m.assign_parent_roles("maus", "bock", gof) == ("bock", "maus")) check( "self-pairing collapses to gender-correct single role (male→father)", m.assign_parent_roles("bock", "bock", gof) == ("bock", None), ) check( "self-pairing collapses to gender-correct single role (female→mother)", m.assign_parent_roles("maus", "maus", gof) == (None, "maus"), ) check( "female in father slot, empty mother → moved to mother", m.assign_parent_roles("maus", None, gof) == (None, "maus"), ) check( "male in mother slot, empty father → moved to father", m.assign_parent_roles(None, "bock", gof) == ("bock", None), ) check( "two males → keep one father, drop impossible second", m.assign_parent_roles("bock", "bock2", gof) == ("bock", None), ) check( "two females → keep one mother, drop impossible second", m.assign_parent_roles("maus", "maus2", gof) == (None, "maus"), ) check( "male + unknown → unknown fills mother", m.assign_parent_roles("bock", "u", gof) == ("bock", "u"), ) check( "female + unknown → unknown fills father", m.assign_parent_roles("u", "maus", gof) == ("u", "maus"), ) check("both empty → both None", m.assign_parent_roles(None, None, gof) == (None, None)) check( "single unknown parent kept as father", m.assign_parent_roles("u", None, gof) == ("u", None), ) # ── name helpers ── check("names_no_conflict: one side empty", m.names_no_conflict(litter(None, fname="A"), litter(None))) check( "names_no_conflict: contradiction detected", not m.names_no_conflict(litter(None, fname="A"), litter(None, fname="B")), ) check("names_overlap: matching father name", m.names_overlap(litter(None, fname="A"), litter(None, fname="A"))) check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B"))) # ── canon_name_key: v.d. ↔ von den abbreviation folds to one key (#30) ── check("canon_name_key: v.d. and von den fold equal", m.canon_name_key("BlackFire v.d. Kleinen Chaoten") == m.canon_name_key("BlackFire von den Kleinen Chaoten")) check("canon_name_key: distinct names stay distinct", m.canon_name_key("Theodore von den Kleinen Chaoten") != m.canon_name_key("Tony von den Kleinen Chaoten")) # ── is_external_origin: pet-shop / private / foreign founders (#5/#13/#28) ── check("merge is_external_origin: 'von Privat'", m.is_external_origin("Bill von Privat")) check("merge is_external_origin: 'vom Zooladen (OBI)'", m.is_external_origin("Cooky vom Zooladen (OBI)")) check("merge is_external_origin: foreign Croatia", m.is_external_origin("Zadar from Zeko i ptica, Croatia")) check("merge is_external_origin: 'of Black Forest' NOT external", not m.is_external_origin("Hagrid Rubeus of Black Forest", "Black Forest")) # ── parent_age_plausible: born before child, within ~6y lifespan ── check("age: parent 1y before child → plausible", m.parent_age_plausible("16.04.2021", "27.03.2022")) check("age: parent born AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27")) check("age: parent born SAME day → implausible", not m.parent_age_plausible("2022-03-27", "2022-03-27")) check("age: 9 years older (Jayjay→Solice) → implausible", not m.parent_age_plausible("19.06.2013", "27.03.2022")) check("age: exactly ~5y older → plausible", m.parent_age_plausible("01.06.2017", "01.05.2022")) check("age: 7 years older → implausible", not m.parent_age_plausible("25.10.2018", "13.08.2025")) check("age: unknown parent dob → plausible (can't disprove)", m.parent_age_plausible(None, "2022-03-27")) check("age: unknown litter date → plausible", m.parent_age_plausible("2021-04-16", None)) # ── pick_parent_ref: prefer age-plausible ref, avoid duplicating the other role ── def pref(name, role, dob=None): return {"name": name, "roleGuess": role, "dob": dob} # Solice case: first father ref (Jayjay, no DOB) loses to the dated, plausible Lui. solice_refs = [ pref("Jayjay", "father"), pref("Lui von den Kleinen Chaoten", "mother", "16.04.2021"), pref("Lui von den Kleinen Chaoten", "father", "16.04.2021"), pref("Molly of Black Forest", "mother", "13.09.2021"), ] f = m.pick_parent_ref(solice_refs, "father", "27.03.2022") check("pick: father = plausible-dated Lui, not first-listed Jayjay", f and f["name"] == "Lui von den Kleinen Chaoten") mo = m.pick_parent_ref(solice_refs, "mother", "27.03.2022", avoid_name=f["name"]) check("pick: mother = Molly (Lui avoided as it is the father)", mo and mo["name"] == "Molly of Black Forest") check("pick: a dated-but-impossible ref loses to a plausible one", m.pick_parent_ref([pref("Old", "father", "2010-01-01"), pref("Dad", "father", "2021-01-01")], "father", "2022-03-27")["name"] == "Dad") check("pick: no ref for role → None", m.pick_parent_ref([pref("X", "mother", "2021-01-01")], "father", "2022-03-27") is None) check("pick: single ref is returned", m.pick_parent_ref([pref("Solo", "father")], "father", "2022-03-27")["name"] == "Solo") # Gender-aware: a dated FEMALE ref must not win the father slot over an undated # male/unknown one (Molly regression: Hagrid (unknown, no DOB) vs Danielle # (female, dated) → father must be Hagrid, not Danielle). molly_refs = [ pref("Hagrid Rubeus of Black Forest", "father"), pref("Arya Stark von den Kleinen Chaoten", "mother", "30.06.2020"), pref("Danielle von den Kleinen Chaoten", "father", "04.03.2020"), pref("Hagrid Rubeus of Black Forest", "mother", "18.07.2019"), ] gender = { m.normalize_name("Hagrid Rubeus of Black Forest"): None, # unknown m.normalize_name("Danielle von den Kleinen Chaoten"): "female", m.normalize_name("Arya Stark von den Kleinen Chaoten"): "female", } gof = lambda name: gender.get(m.normalize_name(name)) fr = m.pick_parent_ref(molly_refs, "father", "13.09.2021", gender_of=gof) check("pick(gender): father = unknown-sex Hagrid, not dated female Danielle", fr and fr["name"] == "Hagrid Rubeus of Black Forest") mr = m.pick_parent_ref(molly_refs, "mother", "13.09.2021", avoid_name=fr["name"], gender_of=gof) check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya")) # ── Provenance history: chronological, file-attributed German log ── import json as _json def _hist(prov_json): return _json.loads(prov_json)["history"] # build_entity_provenance carries history through verbatim. _prov = _json.loads( m.build_entity_provenance(["A.xlsx"], 1, notes=["x"], history=["line one"]) ) check("build_entity_provenance includes history key", _prov.get("history") == ["line one"]) check("build_entity_provenance defaults history to []", _json.loads(m.build_entity_provenance(["A.xlsx"], 1)).get("history") == []) # Single-record gerbil history: names the file and the per-field facts. g_single = { "Name": "Picus", "DateOfBirth": "2022-03-27", "Gender": "male", "Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None, "ImportSource": "Stammbaum von Picus Son.xlsx", "_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [], } h = m._build_gerbil_history([g_single], g_single, {}) check("history: first line names the source file", h[0] == "In „Stammbaum von Picus Son.xlsx“ gefunden.") check("history: dob line names file + formatted date", "Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h) check("history: gender line is German + file-attributed", "Geschlecht (männlich) aus „Stammbaum von Picus Son.xlsx“." in h) check("history: genotype line file-attributed", "Genotyp aus „Stammbaum von Picus Son.xlsx“." in h) # Merged gerbil: a field sourced from a DIFFERENT file is attributed to THAT file. g_best = { "Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male", "Genotype": None, "ColorVarietyId": None, "DateOfDeath": None, "ImportSource": "Stammbaum von Picus Son.xlsx", "_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [], } g_other = { "Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male", "Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None, "ImportSource": "Wurfchronik-Detail.docx", "_filename": "Wurfchronik-Detail.docx", "parentRefs": [], } # Genotype was filled from g_other → its line must name the docx file. g_best["Genotype"] = "aa" fs = {"DateOfBirth": g_best, "Gender": g_best, "Genotype": g_other} h2 = m._build_gerbil_history([g_best, g_other], g_best, fs) check("history(merge): genotype attributed to the file that supplied it", "Genotyp aus „Wurfchronik-Detail.docx“." in h2) check("history(merge): dob attributed to primary file", "Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h2) check("history(merge): merge line names the absorbed file", "Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt." in h2) check("history(merge): Wurfchronik line present", "Angaben aus der Wurfchronik übernommen." in h2) # Date formatting helper. check("_de_date: ISO → DD.MM.YYYY", m._de_date("2022-03-27") == "27.03.2022") check("_de_date: passes through non-ISO", m._de_date("unbekannt") == "unbekannt") # ── Discard history: a discarded source value records reason + replacement ── # _format_discard: majority-vote conflict (losing value + file → winner + file). _d_mehr = m._format_discard({ "label": "Geburtsdatum", "value": "14.06.2015", "file": "A.xlsx", "reason": "abweichend", "replacement": "14.06.2017", "repl_file": "B.xlsx", "replacement_note": "Mehrheit", }) check("discard: starts with warning marker", _d_mehr.startswith(m.DISCARD_MARK)) check("discard(majority): names losing value + its file", "Geburtsdatum 14.06.2015 aus „A.xlsx“ verworfen" in _d_mehr) check("discard(majority): states the reason", "— abweichend" in _d_mehr) check("discard(majority): names replacement + its file + note", "14.06.2017 aus „B.xlsx“ verwendet (Mehrheit)." in _d_mehr) # _format_discard: a parent dropped with NO replacement. _d_noerepl = m._format_discard({ "text": "Vater „Jayjay“ (*19.06.2013) verworfen — unplausibel (9 Jahre älter " "als das Kind); kein Ersatz", }) check("discard(text): verbatim text gets the warning marker", _d_noerepl == m.DISCARD_MARK + "Vater „Jayjay“ (*19.06.2013) verworfen — " "unplausibel (9 Jahre älter als das Kind); kein Ersatz") # explain_pick_rejections: a wrong-sex father candidate is explained (Molly case). _picks = m.explain_pick_rejections( molly_refs, "father", "13.09.2021", fr, gender_of=gof, ) _pick_father = next((d for d in _picks if "Danielle" in (d.get("value") or "")), None) check("pick-reject: wrong-sex father candidate is recorded", _pick_father is not None) check("pick-reject: reason = wrong sex for the father role", _pick_father and "falsches Geschlecht für die Vaterrolle" in _pick_father["reason"]) check("pick-reject: replacement names the chosen Hagrid", _pick_father and "Hagrid" in (_pick_father.get("replacement") or "")) # explain_pick_rejections: an age-impossible candidate is explained. _age_refs = [pref("Old", "father", "2010-01-01"), pref("Dad", "father", "2021-01-01")] _chosen = m.pick_parent_ref(_age_refs, "father", "2022-03-27") _age_picks = m.explain_pick_rejections(_age_refs, "father", "2022-03-27", _chosen) check("pick-reject(age): age-impossible candidate recorded with reason", any("unplausibles Alter" in d["reason"] for d in _age_picks)) # _build_gerbil_history threads field_discards (after merge) and parent_discards # (after the parent line) into the timeline. g_disc = { "Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male", "Genotype": None, "ColorVarietyId": None, "DateOfDeath": None, "ImportSource": "Stammbaum.xlsx", "_filename": "Stammbaum.xlsx", "parentRefs": [], "_discarded": [{"text": "Vater „Jayjay“ verworfen — unplausibel; kein Ersatz"}], } h_disc = m._build_gerbil_history( [g_disc], g_disc, {}, field_discards=[{ "label": "Geschlecht", "value": "weiblich", "file": "Wurfchronik.docx", "reason": "abweichend", "replacement": "männlich", "repl_file": "Stammbaum.xlsx", "replacement_note": "Mehrheit", }], parent_discards=g_disc["_discarded"], ) check("history: field-discard line present (majority vote)", any("Geschlecht weiblich aus „Wurfchronik.docx“ verworfen" in s for s in h_disc)) check("history: parent-discard line present (dropped parent)", any("Vater „Jayjay“ verworfen" in s for s in h_disc)) check("history: discard lines carry the warning marker", all(s.startswith(m.DISCARD_MARK) for s in h_disc if "verworfen" in s)) # ── enrich_from_contracts: SaleContract record emission ─────────────────────── # A contract whose buyer resolves to a contact and whose animal call-name matches # a breeder-owned gerbil must yield a SaleContract record carrying the buyer # ContactId, a deterministic Id, the parsed dates and the matched gerbil id. def _balu(): # A breeder-owned ("Chaoten") gerbil whose call-name is "Balu". return { "Id": "11111111-1111-1111-1111-111111111111", "Name": "Balu von den kleinen Chaoten", "Gender": "male", "DateOfBirth": "2022-05-01", "OriginBreeder": "Zucht der kleinen Chaoten", "Status": "Active", "ColorVarietyId": None, "Provenance": None, } _g = _balu() _resolved = [_g] _contacts_by_norm = {} _contracts = [{ "sourceFile": "Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx", "buyer": "Max Muster", "animals": ["Balu"], "color": "schwarz", "gender": "Male", "dob": "2022-05-01", "handoverDate": "2022-07-01", "contractDate": "2022-07-01", "price": "30,00", }] _stats, _sale = m.enrich_from_contracts(_contracts, _resolved, _contacts_by_norm, {}) check("contracts: exactly one SaleContract record emitted", len(_sale) == 1) _rec = _sale[0] if _sale else {} check("contracts: record Id is deterministic from filename", _rec.get("Id") == m.generate_guid( "contract-Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx")) check("contracts: record ContactId is the resolved buyer contact", _rec.get("ContactId") and _rec["ContactId"] == _contacts_by_norm.get(m.normalize_name("Max Muster"), {}).get("Id")) check("contracts: record lists the matched gerbil", _rec.get("Animals") == [_g["Id"]]) check("contracts: price parsed as float", _rec.get("Price") == 30.0) check("contracts: dates carried through", _rec.get("HandoverDate") == "2022-07-01" and _rec.get("ContractDate") == "2022-07-01") check("contracts: stats count the created record", _stats.get("records_created") == 1) # A dateless contract is skipped from record creation (non-nullable DateOnly) but # still counted, and the buyer contact is still created. _c2 = [{ "sourceFile": "Zucht der kleinen Chaoten _ (Nala) - Erika Muster_.docx", "buyer": "Erika Muster", "animals": ["Nala"], "color": "", "gender": "", "dob": "", "handoverDate": "", "contractDate": "", "price": "", }] _stats2, _sale2 = m.enrich_from_contracts(_c2, [], {}, {}) check("contracts: dateless contract skipped from records", len(_sale2) == 0) check("contracts: dateless contract counted", _stats2.get("dateless_skipped") == 1) # Price-only / no-date fallback: contract with only a contractDate gets it copied # into HandoverDate too (and vice versa), and an animal-less contract still # becomes a record (better to show it than drop it). _c3 = [{ "sourceFile": "Zucht der kleinen Chaoten _ (Unbekannt) - Tom Muster_.docx", "buyer": "Tom Muster", "animals": ["Unbekannt"], "color": "", "gender": "", "dob": "", "handoverDate": "", "contractDate": "2023-01-15", "price": "", }] _stats3, _sale3 = m.enrich_from_contracts(_c3, [], {}, {}) check("contracts: animal-less contract still becomes a record", len(_sale3) == 1) check("contracts: missing handover falls back to contract date", _sale3 and _sale3[0]["HandoverDate"] == "2023-01-15" and _sale3[0]["ContractDate"] == "2023-01-15") check("contracts: animal-less record has empty Animals list", _sale3 and _sale3[0]["Animals"] == []) # ── resolve_color_and_genotype + clean_color_name (genetics-farbschlag cluster) ── # A tiny synthetic variety_map (name->id) with the keys these cases need. _VM = { "gold": "ID-gold", "goldfuchs": "ID-goldfuchs", "goldfuchsschimmel": "ID-gfs", "agouti": "ID-agouti", "dilute agouti": "ID-dagouti", "anthrazit": "ID-anthrazit", "dilute anthrazit": "ID-danthrazit", "blaufuchs": "ID-blaufuchs", "blaufuchsschimmel": "ID-bfs", "kohlfuchsschimmel": "ID-kfs", "marder": "ID-marder", "schwarz": "ID-schwarz", "orangeschimmel": "ID-orange", } _VG = {} def _rc(color, geno): return m.resolve_color_and_genotype(color, geno, _VM, _VG)[0] # Ticket 3f5942a2 — specificity: „Goldfuchs"-label must NOT collapse to „Gold". check("3f5942a2 label: 'Goldfuchs' -> goldfuchs (not gold)", m._match_color_label("goldfuchs", _VM) == "ID-goldfuchs") # Genotype wins: ee fox genotype overrides a stale „Gold" label. check("3f5942a2 genotype wins: ee -> Goldfuchs over 'Gold' label", _rc("Gold", "AA CC DD ee GG pp spsp") == "ID-goldfuchs") # Ticket 998087e2 — dd ignored by label: genotype gives Dilute Agouti. check("998087e2: dd genotype -> Dilute Agouti over 'Agouti' label", _rc("Agouti", "AA CC dd EE GG PP spsp") == "ID-dagouti") # Ticket 06217eb3 — Dilute Anthrazit. check("06217eb3: dd genotype -> Dilute Anthrazit over 'Anthrazit'", _rc("Anthrazit", "aa CC dd Ee gg P- spsp") == "ID-danthrazit") # Ticket 1aac054f — Kohlfuchsschimmel over a stale 'Gold' label. check("1aac054f: ee[f] genotype -> Kohlfuchsschimmel over 'Gold'", _rc("Gold", "aa Cc[chm] D- ee[f] Gg Pp Spsp") == "ID-kfs") # Ticket e22764aa — „Blaufuchs(schimmel)" parenthetical is NOT definitive; the # cleaned label is „blaufuchs" and the ee[-] genotype confirms Blaufuchs. _cn, _sc = m.clean_color_name("Blaufuchs(schimmel)") check("e22764aa: '(schimmel)' stripped, not promoted -> 'blaufuchs'", _cn == "blaufuchs") check("e22764aa: ee[-] genotype -> Blaufuchs (not Blaufuchsschimmel)", _rc("Blaufuchs(schimmel)", "aa C- D- ee[-] gg P- spsp") == "ID-blaufuchs") # Ticket e09d6f22 — a Schecke-looking LABEL must not flip an explicit source spsp # to Spsp (the source genotype is authoritative for the Sp-locus). _, _g_spsp = m.resolve_color_and_genotype("Kohlfuchsschimmel, hell", "aa Cc[chm] D- ee[f] Gg Pp spsp", _VM, _VG) check("e09d6f22: explicit spsp kept (label-Schecke does not force Spsp)", "Spsp" not in _g_spsp and "spsp" in _g_spsp) # VORSICHTIG guard: a COMPACT-notation genotype (cchmcchm/efef) the parser can't # read must fall back to the text label, NOT mis-recolour (e.g. Marder->Schwarz). check("guard: compact 'cchmcchm' unparsable -> keep label 'Marder'", _rc("Marder", "aa cchmcchm DD EE GG PP spsp rere") == "ID-marder") check("guard: compact 'efef' unparsable -> keep label 'Orangeschimmel'", _rc("Orangeschimmel", "AA CC DD efef GG PP spsp rere") == "ID-orange") # A genuinely Schecke label with no Sp in the genotype still appends Spsp. _, _g_add = m.resolve_color_and_genotype("Agouti Schecke", "AA CC DD EE GG PP", _VM, _VG) check("schecke label + no Sp token -> appends Spsp", "Spsp" in _g_add) # ── parse_death_info: markerless death dates (Ticket ec9267b9) ────────────────── # A death keyword without a +/cross marker before the date must still yield the # death date. Status logic is unchanged; an existing dateOfDeath is never replaced. _st, _dod, _cod = m.parse_death_info( "Verstorbener Welpe am 30.03.15 an Durchfall nach frühem Abstillen wegen Tod der Mutter.", "Breeding", None, None) check("ec9267b9: markerless 'Verstorben ... am DD.MM.YY' -> Deceased", _st == "Deceased") check("ec9267b9: markerless death date parsed (30.03.15 -> 2015-03-30)", _dod == "2015-03-30") # 'Verstorben am DD.MM.YY' (the GivenAway-then-died notes) — date now captured. _st2, _dod2, _ = m.parse_death_info("Verstorben am 04.12.18 an Leberzyste", "GivenAway", None, None) check("ec9267b9: 'Verstorben am 04.12.18' -> Deceased + date", _st2 == "Deceased" and _dod2 == "2018-12-04") # Never overwrite an existing dateOfDeath. _st3, _dod3, _ = m.parse_death_info("Verstorben am 04.12.18", "Breeding", "2017-01-01", None) check("ec9267b9: existing dateOfDeath kept (not overwritten)", _dod3 == "2017-01-01") # No death keyword -> no spurious date / status change. _st4, _dod4, _ = m.parse_death_info("Geboren am 30.03.15", "Breeding", None, None) check("ec9267b9: no death keyword -> Status/date unchanged", _st4 == "Breeding" and _dod4 is None) # ── enrich_from_contracts: excludeContractMatch gate (parent-not-pup) ─────────── # A parent animal pinned out via excludeContractMatch must NEVER receive a # contract's receiver/gohome/GivenAway, even when its call-name matches. _excl_gerbils = [{ "Id": "g-makoto", "Name": "Makoto von den Kleinen Chaoten", "Gender": "male", "Status": "Breeding", "DateOfBirth": "2016-05-01", "ReceiverContactId": None, "GoHomeDate": None, "OriginBreeder": "Zucht der kleinen Chaoten", "ExternalRef": "stammbaum-makoto", "Provenance": None, }] _excl_contract = [{ "sourceFile": "Zucht der kleinen Chaoten _ Balu (Makoto.Ella)-Thomas Weickert.docx", "buyer": "Thomas Weickert", "animals": ["Makoto"], "color": "", "gender": "", "dob": "", "handoverDate": "2017-06-01", "contractDate": "2017-06-01", "price": "", }] _excl_decisions = [{"name": "Makoto", "dob": "2016-05-01", "excludeContractMatch": True}] _es, _ = m.enrich_from_contracts(_excl_contract, _excl_gerbils, {}, {}, exclude_decisions=_excl_decisions) check("excludeContractMatch: pinned parent keeps no receiver", _excl_gerbils[0].get("ReceiverContactId") is None) check("excludeContractMatch: pinned parent stays Breeding (not GivenAway)", _excl_gerbils[0].get("Status") == "Breeding") check("excludeContractMatch: pinned parent gets no GoHomeDate", _excl_gerbils[0].get("GoHomeDate") is None) # Without the exclude, the same match WOULD set the receiver (control). _excl_gerbils2 = [dict(_excl_gerbils[0])] m.enrich_from_contracts(_excl_contract, _excl_gerbils2, {}, {}, exclude_decisions=[]) check("excludeContractMatch: control (no exclude) -> receiver IS set", _excl_gerbils2[0].get("ReceiverContactId") is not None) # ── Integration: assert the resolved_import.json output reflects the ticket fixes ── # (Only when the pipeline has already been run; tolerant if the file is absent.) import os as _os, json as _json _resolved = _os.path.join(_os.path.dirname(__file__), "output", "resolved_import.json") if _os.path.exists(_resolved): _d = _json.load(open(_resolved, encoding="utf-8")) _G = {g["Id"]: g for g in _d["gerbils"]} _L = {l["Id"]: l for l in _d["litters"]} def _find(sub, dob=None): sub = sub.lower() for g in _d["gerbils"]: if sub in g["Name"].lower() and (dob is None or g.get("DateOfBirth") == dob): return g return None def _parents(g): l = _L.get(g.get("LitterId")) if g else None if not l: return (None, None) f = _G.get(l.get("FatherId")) m = _G.get(l.get("MotherId")) return (f["Name"] if f else None, m["Name"] if m else None) # #5/#13/#28: external founders → no parents for tag, nm in [("#5 Bill", "Bill von Privat"), ("#13 Cooky", "Cooky vom Zooladen"), ("#28 Zadar", "Zadar from Zeko")]: g = _find(nm) check(f"{tag}: external founder has no litter/parents", g is not None and not g.get("LitterId")) # #18: Hagrid is a SINGLE resolved record (the DOB-less shell merged away) _hag = [g for g in _d["gerbils"] if g["Name"].lower() == "hagrid rubeus of black forest"] check("#18 Hagrid: exactly one resolved record", len(_hag) == 1) if _hag: # #17/#20: external ancestor is NOT resident; parents Snickers × Milka check("#17/#20 Hagrid: isResident == False", _hag[0].get("IsResident") is False) f, mo = _parents(_hag[0]) check("#18 Hagrid: father Snickers, mother Milka", (f or "").startswith("Snickers") and (mo or "").startswith("Milka")) # #2 Mozart → female; #16 Arya, #23 Yuki, #36 Gold parent corrections _moz = _find("Mozart of Lennylengo") check("#2 Mozart: gender female", _moz is not None and _moz.get("Gender") == "female") def _check_parents(tag, nm, exp_f, exp_m): g = _find(nm) f, mo = _parents(g) check(f"{tag}: father ~ {exp_f}", (f or "").lower().startswith(exp_f.lower())) check(f"{tag}: mother ~ {exp_m}", (mo or "").lower().startswith(exp_m.lower())) _check_parents("#16 Arya", "Arya Stark von den Kleinen", "Vance", "Sansa Stark") _check_parents("#23 Yuki", "Yuki von den Kleinen", "Chevrolet Camaro", "Izumi") _check_parents("#36 Gold", "Gold v.d. Kleinen", "Trogir", "Chelsea") _check_parents("#35 Zac", "Zac gen. Action", "Vance", "Dorie") _check_parents("#9 Beatrice", "Beatrice von den kleinen", "Dante", "Malina") _check_parents("#30 Theodore", "Theodore von den Kleinen", "BlackFire", "Katara") # ── New ticket-triage fixes (non-genetics import cluster) ───────────────── # Akane: Roni is the FATHER (gender flipped male), mother = Fumi (stub). _check_parents("Akane (wrong-parents)", "Akane", "Roni", "Fumi") _roni = next((g for g in _d["gerbils"] if g["Name"] == "Roni" and g.get("DateOfBirth") == "2022-01-27"), None) check("Roni: gender flipped to male", _roni is not None and _roni.get("Gender") == "male") # Fumi: materialised stub that is the (Wurfchronik) MOTHER of T21/Z21/22 → # under the isResident-Sweep (Ticket 381f7e51) a parent of her OWN litters is # resident. (No isResident override in conflict-decisions, so the sweep decides.) _fumi = _find("Fumi von den Kleinen") check("Fumi: materialised; resident as a parent of her own Wurfchronik litters", _fumi is not None and _fumi.get("IsResident") is True) # Sunny von PZ Karl: father corrected Hiro → Bill von Privat. _check_parents("Sunny (parents)", "Sunny von PZ Karl", "Bill von Privat", "Melly von Privat") # ── Akane (Ticket 36a3fcde): addLitters injection + isResident/notes/receiver # overrides + ShowInChronicle. The manually-added litter must hang off the # EXISTING Akane and the new Bonaparte stub, link the three children, and be # hidden from the Wurfchronik. ─────────────────────────────────────────── _akane = _find("Akane", "2023-01-21") check("Akane: record present (*2023-01-21)", _akane is not None) if _akane: check("Akane: isResident override == False", _akane.get("IsResident") is False) check("Akane: receiver = Ulrike Neu", _akane.get("ReceiverContactId") == "33130df1-e164-54ae-be74-a9b7d2ced11b") check("Akane: Lebenslauf-Notes mention Ulrike Neu", "Ulrike Neu" in (_akane.get("Notes") or "")) # The injected litter (deterministic ExternalRef from the slug). _alit = next((l for l in _d["litters"] if l.get("ExternalRef") == "decision-litter-akaneswurfbeiclanofblackforest"), None) check("addLitters: Akane litter injected", _alit is not None) if _alit: check("addLitters: ShowInChronicle == False", _alit.get("ShowInChronicle") is False) check("addLitters: mother is Akane", _akane is not None and _alit.get("MotherId") == _akane["Id"]) _bona = _G.get(_alit.get("FatherId")) check("addLitters: father is Bonaparte von den Schlossmäusen", _bona is not None and _bona["Name"].startswith("Bonaparte")) # The three children must point their LitterId at the injected litter. _kids = {g["Name"]: g for g in _d["gerbils"] if g.get("LitterId") == _alit["Id"]} for _kn in ("Merle", "Fanella", "Pete"): check(f"addLitters: child {_kn} linked to the litter", _kn in _kids) check("addLitters: Merle is the new resident stub (not 'Merle of Samsimar')", _kids.get("Merle") is not None and _kids["Merle"].get("IsResident") is True) check("addLitters: Pete resident", _kids.get("Pete") is not None and _kids["Pete"].get("IsResident") is True) check("addLitters: Fanella non-resident, receiver Ulrike Neu", _kids.get("Fanella") is not None and _kids["Fanella"].get("IsResident") is False and _kids["Fanella"].get("ReceiverContactId") == "33130df1-e164-54ae-be74-a9b7d2ced11b") # ShowInChronicle (Ticket ea41257a): present on EVERY litter. The Wurfchronik # shows only the breeder's own documented litters. Pure ancestor pairings # (reconstructed from Stammbaum diagrams, NOT from the Wurfchronik, with NO # resident parent) are hidden; the Akane addLitter stays hidden too; every # real Wurfchronik litter and any reconstructed litter with a resident parent # stays visible. check("ShowInChronicle: present on every litter", all("ShowInChronicle" in l for l in _d["litters"])) def _prov_of(l): try: return _json.loads(l.get("Provenance") or "{}") except Exception: return {} def _is_reconstructed(l): p = _prov_of(l) notes = " ".join(p.get("notes") or []).lower() return ("rekonstruiert" in notes) and not p.get("fromWurfchronik") def _has_resident_parent(l): for pid in (l.get("FatherId"), l.get("MotherId")): g = _G.get(pid) if g and g.get("IsResident"): return True return False _hidden = [l for l in _d["litters"] if l.get("ShowInChronicle") is False] # (a) the manually injected Akane litter is still hidden. check("ShowInChronicle: Akane addLitter still hidden", any(l.get("ExternalRef") == "decision-litter-akaneswurfbeiclanofblackforest" for l in _hidden)) # (b) every pure ancestor pairing (reconstructed, no resident parent) is hidden; # and there is at least one such litter (the ticket case 47f8d2b6). _pure_ancestor = [l for l in _d["litters"] if _is_reconstructed(l) and not _has_resident_parent(l)] check("ShowInChronicle: at least one pure ancestor pairing exists", len(_pure_ancestor) > 0) check("ShowInChronicle: every pure ancestor pairing is hidden", all(l.get("ShowInChronicle") is False for l in _pure_ancestor)) # (c) the concrete ticket litter 47f8d2b6 (Antares × Charly) is hidden. _ticket_lit = _L.get("47f8d2b6-bf13-5f94-935c-f8860a5a1ce6") check("ShowInChronicle: ticket litter 47f8d2b6 present", _ticket_lit is not None) if _ticket_lit: check("ShowInChronicle: ticket litter 47f8d2b6 hidden", _ticket_lit.get("ShowInChronicle") is False) # (d) regression — a real Wurfchronik litter stays visible; and reconstructed # litters WITH a resident parent (the breeder's own, only charted) stay # visible, so her real litters are never hidden. _wurfchronik_lits = [l for l in _d["litters"] if _prov_of(l).get("fromWurfchronik")] check("ShowInChronicle: Wurfchronik litters present", len(_wurfchronik_lits) > 0) check("ShowInChronicle: every Wurfchronik litter visible", all(l.get("ShowInChronicle") is True for l in _wurfchronik_lits)) _recon_resident = [l for l in _d["litters"] if _is_reconstructed(l) and _has_resident_parent(l)] check("ShowInChronicle: reconstructed litter with resident parent stays visible", all(l.get("ShowInChronicle") is True for l in _recon_resident)) # Danielle: mother = Ella *10.06.2019, father = Makoto (sibling pairing; Ticket 4692fd5c). _dan = _find("Danielle von den Kleinen") _df, _dm = _parents(_dan) check("Danielle: mother is Ella", (_dm or "") == "Ella") check("Danielle: father is Makoto", (_df or "").startswith("Makoto")) if _dan and _dan.get("LitterId"): _dl = _L.get(_dan["LitterId"]) _dmom = _G.get(_dl.get("MotherId")) if _dl else None check("Danielle: mother Ella is the *2019-06-10 one (not the *2023 Ella)", _dmom is not None and _dmom.get("DateOfBirth") == "2019-06-10") # Eddy: ticket 439b02e4 & Eliza: ticket b43a5e67 _eddy = _find("Eddy von den Kleinen") _eliza = _find("Eliza", "2010-08-20") check("Eddy: present", _eddy is not None) check("Eliza: present", _eliza is not None) if _eddy and _eliza: _el = _L.get(_eddy.get("LitterId")) check("E-Wurf 2010: present", _el is not None) if _el: check("E-Wurf 2010: father is Blacky", _el.get("FatherId") is not None and _G[_el["FatherId"]]["Name"].startswith("Blacky")) check("E-Wurf 2010: mother is Kruke", _el.get("MotherId") is not None and _G[_el["MotherId"]]["Name"] == "Kruke") check("Eliza: shares same litter with Eddy", _eliza.get("LitterId") == _eddy.get("LitterId")) # Zeus + Beatrice vs Eddy + Sheila same-date litters (2012-06-13) _k1 = next((l for l in _d["litters"] if l.get("Name") == "Wurf K1" and l.get("Date") == "2012-06-13"), None) _ed_sh = next((l for l in _d["litters"] if "Eddy" in l.get("Name") and "Sheila" in l.get("Name") and l.get("Date") == "2012-06-13"), None) check("K1-Wurf: present on 2012-06-13", _k1 is not None) check("Eddy+Sheila litter: present on 2012-06-13", _ed_sh is not None) if _k1 and _ed_sh: check("K1-Wurf and Eddy+Sheila litter are distinct", _k1["Id"] != _ed_sh["Id"]) check("K1-Wurf has correct father Zeus", _k1.get("FatherId") is not None and _G[_k1["FatherId"]]["Name"].startswith("Zeus")) check("K1-Wurf has correct mother Beatrice", _k1.get("MotherId") is not None and _G[_k1["MotherId"]]["Name"].startswith("Beatrice")) check("Eddy+Sheila litter is hidden from chronicle", _ed_sh.get("ShowInChronicle") is False) # Catelyn (Ticket 7bbc045c): father Eddard Stark of Sunset Glow, mother Milena. _check_parents("Catelyn", "Catelyn Stark von den Kleinen", "Eddard Stark", "Milena") # Gaida (Ticket ba63325a): Geschwisterverpaarung Zhuāngzǐ × Zaibunissa (beide *2020-02-21). _check_parents("Gaida", "Gaida von den Kleinen Chaoten", "Zhuāngzǐ", "Zaibunissa") # Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): suppressed by decision -> None. for _tag, _ref in [("Bentley", "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003"), ("Alexandria", "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004"), ("Bugatti", "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003")]: _g = next((g for g in _d["gerbils"] if g.get("ExternalRef") == _ref), None) check(f"{_tag}: suppressed by decision", _g is None) # Cherry Berry's Quqquluuruu: gender override female (box colour misread). _cherry = _find("Cherry Berry") check("Cherry Berry: gender override female", _cherry is not None and _cherry.get("Gender") == "female") # Origin-label: all 'of Black Forest' animals → 'Clan of Black Forest', no # animal left on the old 'Black Forest' label and no duplicate contact. _bf_left = [g for g in _d["gerbils"] if (g.get("OriginBreeder") or "") == "Black Forest"] check("Origin-label: no animal still on 'Black Forest'", len(_bf_left) == 0) _bf_contacts = [c for c in _d["contacts"] if c["Name"] == "Black Forest"] check("Origin-label: stray 'Black Forest' contact merged away", len(_bf_contacts) == 0) # Duplicate-merge: the nameless buck *15.02.2024 (son of Inochi gen. Picu) # exists only once after the externalRef merge. _bucks = [g for g in _d["gerbils"] if g.get("DateOfBirth") == "2024-02-15" and "unbekannt" in (g.get("ExternalRef") or "").lower()] check("Duplicate-merge: nameless buck *15.02.2024 deduped to one record", len(_bucks) == 1) # ── genetics-farbschlag cluster: the STORED colorVarietyId is now genotype- # correct for the ticket animals. Build the id→name map from the authoritative # ApplicationContext.cs catalog (same source the pipeline uses for the ids). import re as _re _app = _os.path.abspath(_os.path.join(_os.path.dirname(__file__), "../../GerbilManagerWebAPI/ApplicationContext.cs")) _idname = {} if _os.path.exists(_app): _cm = _re.search(r"catalog\s*=\s*\{(.*?)\};", open(_app, encoding="utf-8").read(), _re.DOTALL) if _cm: for _i, (_n, _g, _so) in enumerate(_re.findall( r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', _cm.group(1))): _idname[f"00000000-0000-0000-0000-{_i + 1:012d}"] = _n def _by_ref(ref): return next((g for g in _d["gerbils"] if g.get("ExternalRef") == ref), None) def _cv_name(g): return _idname.get(g.get("ColorVarietyId")) if g else None if _idname: # Ticket 1aac054f — namenloses Weibchen *13.08.2025 -> Kohlfuchsschimmel. _t1 = _by_ref("stammbaum-unbekannt-13082025-2") check("1aac054f: nameless *13.08.2025 stored as Kohlfuchsschimmel", _cv_name(_t1) == "Kohlfuchsschimmel") # Ticket e09d6f22 — same litter, *-3: spsp (NOT Schecke) + Kohlfuchsschimmel. _t2 = _by_ref("stammbaum-unbekannt-13082025-3") check("e09d6f22: Sp-locus is spsp (no Schecke)", _t2 is not None and "Spsp" not in (_t2.get("Genotype") or "") and "spsp" in (_t2.get("Genotype") or "")) check("e09d6f22: stored as Kohlfuchsschimmel", _cv_name(_t2) == "Kohlfuchsschimmel") # Ticket 06217eb3 — Dilute Anthrazit (dd). _t3 = _by_ref("stammbaum-unbekannt-27052025") check("06217eb3: nameless dd-Weibchen stored as Dilute Anthrazit", _cv_name(_t3) == "Dilute Anthrazit") # Ticket e22764aa — Blaufuchs (NOT Blaufuchsschimmel). _t4 = _by_ref("stammbaum-unbekannt-16012026") check("e22764aa: '(schimmel)' animal stored as Blaufuchs", _cv_name(_t4) == "Blaufuchs") # Ticket 3f5942a2 — named fox animals are Goldfuchs (ee), not Gold (EE). _banjo = _find("Banjo of Fiomi") check("3f5942a2: Banjo of Fiomi stored as Goldfuchs", _cv_name(_banjo) == "Goldfuchs") # ── Mamta Mini (cc9ea3fe / 1a508c04): Ee[-] resolved to Ee + parents linked. ── _mamta = _find("Mamta Mini") check("Mamta Mini: E-locus resolved to Ee (no unknown [-])", _mamta is not None and "Ee[-]" not in (_mamta.get("Genotype") or "") and "Ee" in (_mamta.get("Genotype") or "")) _mf, _mm = _parents(_mamta) check("Mamta Mini: father Geely, mother Gaida linked at the litter", (_mf or "").startswith("Geely") and (_mm or "").startswith("Gaida")) # ── isResident-Sweep (Ticket 381f7e51): „ALLE raus, AUSSER Elterntiere" ─────── # Datengetriebene Endregel: resident GENAU DANN, wenn (1) expliziter Override # ODER (2) Elternteil eines EIGENEN Wurfs (Wurfchronik/Clan/resident-Elternteil), # NICHT bloß eines rein-virtuellen Ahnen-Wurfs. _byid = lambda pref: next((g for g in _d["gerbils"] if g.get("Id", "").startswith(pref)), None) # (a) Jungtier ohne eigene Nachzucht (namenloses ♀ *2022-01-28, O20) → False. _o20 = _byid("86c10cf2") check("Sweep: jungling 86c10cf2 (no own offspring) → isResident False", _o20 is not None and _o20.get("IsResident") is False) # (b) Mindestens 90 % aller Tiere sind jetzt NICHT-resident (der Blanket-True- # Default der Wurfchronik ist weg; vorher waren ~1709/2357 resident). _n_res = sum(1 for g in _d["gerbils"] if g.get("IsResident")) check("Sweep: resident count drastically reduced (< 700)", _n_res < 700) # (c) Echte Zuchttiere (Eltern dokumentierter Wurfchronik-Würfe) bleiben True. def _is_wurfchronik(l): try: return bool(_json.loads(l.get("Provenance") or "{}").get("fromWurfchronik")) except Exception: return False _wc_parents_true = 0 _wc_parents_total = 0 for l in _d["litters"]: if not _is_wurfchronik(l): continue for pid in (l.get("FatherId"), l.get("MotherId")): pg = _G.get(pid) if not pg: continue _wc_parents_total += 1 if pg.get("IsResident"): _wc_parents_true += 1 # The vast majority of Wurfchronik parents are her own breeding stock → resident # (a handful are explicit-false ancestors like Bentley/Alexandria). check("Sweep: ≥ 95% of Wurfchronik-litter parents stay resident", _wc_parents_total > 0 and _wc_parents_true / _wc_parents_total >= 0.95) # (d) Externer Ahne, nur Elternteil eines virtuellen Ahnen-Wurfs → False. _antares = _find("Antares of Ulmer Strolche") check("Sweep: external ancestor Antares (virtual-only litter) → isResident False", _antares is not None and _antares.get("IsResident") is False) # (e) Expliziter false-Override gewinnt über Elternschaft (Akane *2023-01-21 ist # Elternteil ihres Fremd-Wurfs, bleibt aber False per Override). _ak = _find("Akane", "2023-01-21") check("Sweep: explicit false override beats parenthood (Akane → False)", _ak is not None and _ak.get("IsResident") is False) # (f) Residenten, die KEIN Elternteil irgendeines Wurfs sind, sind ausschließlich # explizite Overrides (z. B. die addAnimals-Stubs Merle/Pete) — der Sweep # macht niemanden ohne Elternschaft resident. _parent_ids = set() for l in _d["litters"]: for pid in (l.get("FatherId"), l.get("MotherId")): if pid: _parent_ids.add(pid) # Ein expliziter isResident:true-Override in conflict-decisions.json macht ein # NICHT-Elterntier legitim resident (Züchterin-Entscheidung, z. B. „extern"-Badge # entfernen). Deren Match-Keys (name / externalRef) werden neben den decision--Stubs # akzeptiert — sonst würde jede solche Kuratierung diese Invariante verletzen. _cd_path = _os.path.join(_os.path.dirname(__file__), "conflict-decisions.json") _ovr_names, _ovr_refs = set(), [] if _os.path.exists(_cd_path): for _r in (_json.load(open(_cd_path, encoding="utf-8")).get("resolutions") or []): if _r.get("isResident") is True: if _r.get("name"): _ovr_names.add(m.normalize_name(_r["name"])) if _r.get("externalRef"): _ovr_refs.append(_r["externalRef"]) def _res_ok(g): er = g.get("ExternalRef") or "" return (er.startswith("decision-") or m.normalize_name(g.get("Name") or "") in _ovr_names or any(er.endswith(ref) for ref in _ovr_refs)) _res_nonparent = [g for g in _d["gerbils"] if g.get("IsResident") and g["Id"] not in _parent_ids] check("Sweep: residents that are not a parent are only explicit override stubs", all(_res_ok(g) for g in _res_nonparent)) else: print("note: output/resolved_import.json not present — skipped integration assertions") if check.failed: print(f"\n{check.failed} test(s) FAILED") sys.exit(1) print("\nAll merge_and_resolve tests passed.")