Files
GerbilManager/tools/import/test_merge_resolve.py
Gulum 5a6d1c2541
All checks were successful
CI / Frontend Tests (Node/Vite) (push) Successful in 9m38s
CI / Backend Tests (.NET) (push) Successful in 1m14s
CI / Docker Build & Push (push) Successful in 1m33s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 8s
fix(tickets): 16 Tickets der Züchterin — Genetik, Importer, Daten, Akte
Genetik-Engine (bde4ec70, f89e95ad, 2322c2a8):
- Saphir/Platin/Platin-Hell unterscheiden sich NUR in der C-Zygotie und waren im
  Katalog identisch (locusToken reduziert auf das dominante Allel) → Saphir war
  unerreichbar. Token darf jetzt ein exaktes, ungeordnetes Allelpaar "x/y"
  verlangen: Platin C/C, Saphir C/cchm, Platin-Hell C/ch. TS- und Python-Mirror
  identisch, Seeds regeneriert, Migration ReseedColorVarietiesGen6Saphir.
- c[hm] wird als Alias auf c[chm] normalisiert (Tippfehler in zwei Charts) —
  vorher war Jays Gencode unparsebar und ergab "Zobel-Hell".
- 6 Tiere wechseln den errechneten Farbschlag (5x Platin→Saphir, Jay→Zobel).

Importer-Logik:
- renameTo-Notiz-Sweep: der alte Name blieb in Wurf-Notizen stehen ("Blacky +
  Kruke") — jetzt wortgenau ersetzt, mit vier Guards gegen Kollateralschaden.
- Neuer Resolution-Schlüssel goHomeDate (autoritativ, zieht GivenAway nach) und
  spottingType; Gencode-Overrides gewinnen jetzt bei präzisem Match (vorher
  wurden Entscheidungen stumm verschluckt, z. B. Eliza und Chris).
- parse_date verwirft implausible Jahre (JackJack hatte 1310-05-13).
- extract.py erkennt Stammbaum-Blöcke auch ohne Stern vor dem Geburtsdatum
  (Bijou bekam dadurch den Nachbar-Ast als Mutter).
- Backend-Ingest überträgt SpottingType (nur bei Payload-Wert).

Daten (conflict-decisions.json, re-ingest-stabil): Kuke-Merge + Zuchtname,
Jamie- und Sakura-Dubletten, Phantom-Tier "Unbekannt", Eltern von Kathlin,
Fast Boy/Ziwa und dem Q4-/TS-Wurf, Eliza-Gencode, Merle-Abgabe, JackJack.

Frontend (24522f5f): Abgabedatum steht in der Tier-Akte jetzt direkt unter dem
Abnehmer und hängt nicht mehr am Status.

Tests: +25 Checks test_merge_resolve, +7 test_genotype, +8 genetics.test.ts,
neuer Extract- und Ingest-Test, e2e GOHOME-ROW. Alles grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:32:28 +02:00

1125 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
# ── parse_date: Plausibilitaets-Guard (Ticket 24522f5f, JackJack) ─────────────
# Die Wurfchronik-Transkription baute aus „13.5.10" das Datum „1310-05-13" — solche
# unmoeglichen Jahre muessen verworfen werden, ohne die 19xx→20xx-Normalisierung
# („13.5.10" → 2010-05-13) zu beschaedigen.
check("parse_date: implausibles Jahr 1310 (ISO) -> None",
m.parse_date("1310-05-13") is None)
check("parse_date: implausibles Jahr 1310 (DD.MM.YYYY) -> None",
m.parse_date("13.05.1310") is None)
check("parse_date: 2-stelliges Jahr bleibt intakt ('13.5.10' -> 2010-05-13)",
m.parse_date("13.5.10") == "2010-05-13")
check("parse_date: 19xx wird weiter auf 20xx normalisiert",
m.parse_date("22.12.1924") == "2024-12-22")
check("parse_date: regulaeres Datum unveraendert",
m.parse_date("22.12.2024") == "2024-12-22")
check("parse_date: fernes Zukunftsjahr -> None",
m.parse_date("2999-01-01") is None)
# ── renameTo-Notiz-Sweep (Tickets ea803c3d/24006ccd, Kruke→Kuke) ──────────────
# `renameTo` korrigiert nur das Namensfeld; der alte Name blieb in den aus der
# Wurfchronik uebernommenen Freitext-Notizen stehen. Der finale Sweep ersetzt ihn
# wortgenau (an Wortgrenzen) durch den RUFNAMEN des neuen Namens.
_sw_gerbils = [
{"Name": "Kuke von Marion Knoss", "_pre_rename_name": "Kruke",
"Notes": "Krukelinde bleibt; Kruke wird ersetzt"},
{"Name": "Blacky", "Notes": "Vater von Kruke-Kindern"},
]
_sw_litters = [
{"Notes": "Blacky + Kruke v. 21.09.2010; WS=4"},
{"Notes": None},
{"Notes": "Eltern: Blacky + Bonnie"},
]
_sw_n = m.apply_rename_note_sweep(_sw_gerbils, _sw_litters)
check("Notiz-Sweep: Wurf-Notiz traegt den neuen Rufnamen",
_sw_litters[0]["Notes"] == "Blacky + Kuke v. 21.09.2010; WS=4")
check("Notiz-Sweep: Rufname (nicht der volle Zuchtname) wird eingesetzt",
"von Marion Knoss" not in _sw_litters[0]["Notes"])
check("Notiz-Sweep: Teilwort 'Krukelinde' bleibt unangetastet",
_sw_gerbils[0]["Notes"] == "Krukelinde bleibt; Kuke wird ersetzt")
check("Notiz-Sweep: auch Gerbil-Notizen anderer Tiere werden gefixt",
_sw_gerbils[1]["Notes"] == "Vater von Kuke-Kindern")
check("Notiz-Sweep: unbeteiligte Notizen/None bleiben unveraendert",
_sw_litters[1]["Notes"] is None
and _sw_litters[2]["Notes"] == "Eltern: Blacky + Bonnie")
check("Notiz-Sweep: Zaehler = Zahl der geaenderten Notizen", _sw_n == 3)
# Kein _pre_rename_name → kein Sweep.
_sw_none = [{"Name": "Kuke", "Notes": "Kruke stand hier nie zur Debatte"}]
check("Notiz-Sweep: ohne renameTo passiert nichts",
m.apply_rename_note_sweep(_sw_none, []) == 0)
# Guards gegen Kollateralschaden (alle vier im ersten Probelauf real aufgetreten):
# (1) Zu kurzer Quellname („M") darf keine Abkuerzungen im Freitext treffen.
_sw_short = [{"Name": "JD", "_pre_rename_name": "M",
"Notes": "Andreas B.; 60g; M: 17.3.19"},
{"Name": "Milly", "Notes": "Abgabe an Natascha M."}]
check("Notiz-Sweep: Quellname < 4 Zeichen wird ignoriert",
m.apply_rename_note_sweep(_sw_short, []) == 0
and _sw_short[1]["Notes"] == "Abgabe an Natascha M.")
# (2) Mehrwortige Phrase (Zucht-Sentinel als Quellname) wird ignoriert.
_sw_phrase = [{"Name": "Namenlose Tochter von Unity",
"_pre_rename_name": "Zucht der Kleinen Chaoten",
"Notes": "Herkunft: Zucht der Kleinen Chaoten"}]
check("Notiz-Sweep: mehrwortige Phrase als Quellname wird ignoriert",
m.apply_rename_note_sweep(_sw_phrase, []) == 0)
# (3) Alter Name steckt im neuen Rufnamen → keine Verdopplung des Zusatzes.
_sw_dbl = [{"Name": "Missy gen. Krümmel", "_pre_rename_name": "Missy",
"Notes": "Kinder: Xaki, Rover, Missy gen. Krümmel"}]
check("Notiz-Sweep: kein Verdoppeln, wenn der alte Name im neuen Rufnamen steckt",
m.apply_rename_note_sweep(_sw_dbl, []) == 0
and _sw_dbl[0]["Notes"] == "Kinder: Xaki, Rover, Missy gen. Krümmel")
# (4) Notizen, die die Umbenennung selbst dokumentieren, behalten den alten Namen.
_sw_doc = [{"Name": "Gale von den Kleinen Chaoten", "_pre_rename_name": "Drake Jr",
"Notes": "Hieß ursprünglich Drake Jr, wurde später in Gale umbenannt."}]
_sw_doc_litters = [{"Notes": "Parents: Jacky + Drake Jr"}]
_sw_doc_n = m.apply_rename_note_sweep(_sw_doc, _sw_doc_litters)
check("Notiz-Sweep: Umbenennungs-Dokumentation bleibt erhalten",
_sw_doc[0]["Notes"] == "Hieß ursprünglich Drake Jr, wurde später in Gale umbenannt.")
check("Notiz-Sweep: normale Notiz desselben Paares wird trotzdem gefixt",
_sw_doc_litters[0]["Notes"] == "Parents: Jacky + Gale" and _sw_doc_n == 1)
# ── Decision-Overrides: goHomeDate / genotype (autoritativ) / spottingType ────
# Tickets 24522f5f (JackJack: kaputtes Abgabedatum), 7037f5d8 (Merle: Abgabe zieht
# Status GivenAway nach), a547be62 (Eliza/Chris: Gencode + Scheckungsart).
def _ovr(gerbils, decisions):
return m.apply_decision_overrides(gerbils, decisions, {}, None)
# (1) goHomeDate ist AUTORITATIV — ueberschreibt auch einen (kaputten) Bestandswert.
_g_jj = {"Id": "1", "ExternalRef": "wc-0002", "Name": "JackSack",
"DateOfBirth": "2010-02-18", "Status": "Deceased",
"GoHomeDate": "1310-05-13"}
_ovr([_g_jj], [{"externalRef": "wc-0002", "goHomeDate": "13.05.2010"}])
check("goHomeDate: kaputtes Abgabedatum wird ueberschrieben",
_g_jj["GoHomeDate"] == "2010-05-13")
check("goHomeDate: Status Deceased bleibt Deceased",
_g_jj["Status"] == "Deceased")
# (2) goHomeDate zieht bei nicht-verstorbenen Tieren Status GivenAway nach (Merle).
_g_merle = {"Id": "2", "ExternalRef": "stammbaum-merle-18062023", "Name": "Merle",
"DateOfBirth": "2023-06-18", "Status": "Breeding", "IsResident": True}
_ovr([_g_merle], [{"name": "Merle", "dob": "18.06.2023",
"externalRef": "stammbaum-merle-18062023",
"goHomeDate": "22.12.2024"}])
check("goHomeDate: Abgabedatum gesetzt", _g_merle["GoHomeDate"] == "2024-12-22")
check("goHomeDate: Status wird GivenAway", _g_merle["Status"] == "GivenAway")
check("goHomeDate: Residenz bleibt unangetastet", _g_merle["IsResident"] is True)
# (3) Unplausibles Datum in der Entscheidung wird verworfen (kein Status-Wechsel).
_g_bad = {"Id": "3", "ExternalRef": "wc-9", "Name": "Bad", "Status": "Breeding"}
_ovr([_g_bad], [{"externalRef": "wc-9", "goHomeDate": "13.05.1310"}])
check("goHomeDate: implausibles Datum -> kein Abgabedatum, kein Status-Wechsel",
_g_bad.get("GoHomeDate") is None and _g_bad["Status"] == "Breeding")
# (4) Gencode-Override ist bei PRAEZISEM Match autoritativ (Eliza: der Bestandswert
# ist nur der Kanon-Fill des Farbschlags) und setzt zusaetzlich die Scheckungsart.
_g_eliza = {"Id": "4", "ExternalRef": "page_0013.md-g1000000-011", "Name": "Eliza",
"DateOfBirth": "2010-08-20", "Genotype": "aa CC DD EE GG PP Spsp rere"}
_ovr([_g_eliza], [{"externalRef": "page_0013.md-g1000000-011",
"genotype": "aa C- DD E- G- P- Spsp",
"spottingType": "Ansatzschecke"}])
check("genotype: praeziser Override ueberschreibt den Kanon-Fill",
_g_eliza["Genotype"] == "aa C- DD E- G- P- Spsp")
check("spottingType: Scheckungsart per Entscheidung gesetzt",
_g_eliza["SpottingType"] == "Ansatzschecke")
# (5) name+dob ist ebenfalls praezise, ein Namensvetter mit anderem dob nicht.
_g_named = {"Id": "5", "Name": "Chris", "DateOfBirth": "2010-08-20",
"Genotype": "aa CC DD EE gg PP Spsp rere"}
_g_cousin = {"Id": "6", "Name": "Chris of Elsewhere", "DateOfBirth": "2014-01-01",
"Genotype": "AA CC DD EE GG PP spsp"}
_ovr([_g_named, _g_cousin], [{"name": "Chris", "dob": "20.08.2010",
"genotype": "aa Cc[-] DD Ee gg P- Spsp"}])
check("genotype: name+dob-Match ist praezise und ueberschreibt",
_g_named["Genotype"] == "aa Cc[-] DD Ee gg P- Spsp")
check("genotype: gleichnamiges Tier mit anderem dob bleibt unberuehrt",
_g_cousin["Genotype"] == "AA CC DD EE GG PP spsp")
# (6) name-only-Entscheidung (unpraezise) darf einen vorhandenen Gencode NICHT
# ueberschreiben — sie kann auf einen Namensvetter geleakt sein (Merle of Samsimar).
_g_only = {"Id": "7", "Name": "Vetter", "DateOfBirth": "2012-09-01",
"Genotype": "AA CC DD EE GG PP spsp"}
_ovr([_g_only], [{"name": "Vetter", "genotype": "aa CC DD ee GG PP Spsp"}])
check("genotype: name-only-Override ueberschreibt vorhandenen Gencode NICHT",
_g_only["Genotype"] == "AA CC DD EE GG PP spsp")
# (7) ... fuellt aber weiterhin ein LEERES Feld (bisheriges Verhalten, z. B. Jana/Kyara).
_g_empty = {"Id": "8", "Name": "Jana", "DateOfBirth": "2010-01-01", "Genotype": None}
_ovr([_g_empty], [{"name": "Jana", "genotype": "aa CC DD Ee GG PP spsp"}])
check("genotype: name-only-Override fuellt ein leeres Feld weiterhin",
_g_empty["Genotype"] == "aa CC DD Ee GG PP spsp")
# (8) Eine Entscheidung OHNE die neuen Schluessel bleibt fuer sie ein No-Op.
_g_plain = {"Id": "9", "Name": "Plain", "DateOfBirth": "2020-01-01",
"Genotype": "AA CC DD EE GG PP spsp", "Status": "Breeding"}
_ovr([_g_plain], [{"name": "Plain", "dob": "01.01.2020", "isResident": True}])
check("Overrides: ohne genotype/spottingType/goHomeDate keine Nebenwirkung",
_g_plain["Genotype"] == "AA CC DD EE GG PP spsp"
and _g_plain.get("SpottingType") is None
and _g_plain.get("GoHomeDate") is None
and _g_plain["Status"] == "Breeding")
# ── 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")
# ── litterChildren: over-merged foreign animals removed from datierten Würfen ──
def _kids(lit_id):
return sorted(_G[k]["Name"] for k in _G if _G[k].get("LitterId") == lit_id)
_mw = next((l for l in _d["litters"]
if (l.get("Date") or "").startswith("2011-03-03") and "Cotton" in _kids(l["Id"])), None)
check("4b9f49fb M-Wurf: renamed from 'Ungeklärt' to 'M-Wurf'",
_mw is not None and _mw.get("Name") == "M-Wurf")
if _mw:
check("4b9f49fb M-Wurf: exactly the 5 correct kids",
_kids(_mw["Id"]) == ["Cotton", "Kiwi", "Mayu", "Mio", "Mo"])
_gw = next((l for l in _d["litters"]
if (l.get("Date") or "").startswith("2014-06-21") and "Kai-Uwe" in _kids(l["Id"])), None)
check("8d259edf Gale-Wurf: no wrongly-attached 'Unbekannt' kids remain",
_gw is not None and "Unbekannt" not in _kids(_gw["Id"]))
if _gw:
check("8d259edf Gale-Wurf: 3 surviving named juveniles", len(_kids(_gw["Id"])) == 3)
# ── 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")
# Match on the call-name: since the new Stammbäume (2026-08-18) Roni carries the full
# clan name „Roni von den Kleinen Chaoten“ — and a substring _find("Roni") would hit
# „Vroni of little Jumpers“ first.
_roni = next((g for g in _d["gerbils"]
if g["Name"].startswith("Roni") and g.get("DateOfBirth") == "2022-01-27"), None)
check("Roni: gender flipped to male", _roni is not None and _roni.get("Gender") == "male")
# litterChildren.add (neue Stammbäume 2026-08-18): Akane bleibt im Z21-Wurf (Roni × Fumi);
# der Phantom-Wurf „Irish Coffee × Roni“ aus der Chart-Positions-Heuristik ist entfernt.
_akane = next((g for g in _d["gerbils"]
if g["Name"].startswith("Akane") and g.get("DateOfBirth") == "2023-01-21"), None)
_akane_litter = _L.get(_akane.get("LitterId")) if _akane else None
check("Akane: im Z21-Wurf (nicht im Chart-Phantom-Wurf)",
_akane_litter is not None and _akane_litter.get("Name") == "Z21-Wurf")
check("kein Phantom-Wurf 'Irish Coffee × Roni' vom 21.01.2023",
not any((l.get("Date") == "2023-01-21" and "Irish Coffee" in (l.get("Name") or ""))
for l in _d["litters"]))
# Merle (Ticket 36a3fcde/a8f11ac0): DOB aus „Stammbaum von Pukas Kids“, Zuchttier per Override.
_merle = next((g for g in _d["gerbils"] if g["Name"] == "Merle"), None)
check("Merle: DOB 18.06.2023 aus dem neuen Stammbaum",
_merle is not None and _merle.get("DateOfBirth") == "2023-06-18")
check("Merle: Zuchttier (isResident-Override)",
_merle is not None and _merle.get("IsResident") is True)
# 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"))
# Ticket e0a0c304: 'Kruke' war ein Tippfehler → korrekt 'Kuke'.
# Ticket 24006ccd: renameTo setzt jetzt den vollen Zuchtnamen
# 'Kuke von Marion Knoss' (Merge mit dem Chart-Ahnenrecord), daher Prefix-Pruefung.
check("E-Wurf 2010: mother is Kuke", _el.get("MotherId") is not None and _G[_el["MotherId"]]["Name"].startswith("Kuke"))
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.")