Files
GerbilManager/tools/import/test_merge_resolve.py
Gulum 45b8533f18
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m11s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m59s
CI / Docker Build & Push (push) Has been skipped
CI / Deploy auf TrueNAS (Custom App) (push) Has been skipped
feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
Deployment:
- custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App"
  (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090)
- scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App
  bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check
- ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien
  per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye)
- compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090
- .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch)

Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage):
- GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests
- EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO)
- Frontend VerifizierteTierePage + verified-API + e2e-Spec
- diverse Import-/Triage-Skripte und -Tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:19:11 +02:00

923 lines
47 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)
# ── 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.")