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>
1125 lines
59 KiB
Python
1125 lines
59 KiB
Python
"""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.")
|