Der Herkunfts-Verlauf nennt jetzt auch, wenn ein Wert aus einer Quelle VERWORFEN wurde — mit Grund und was stattdessen verwendet wurde (⚠-Markierung), z. B.: - „⚠ Vater-Kandidat ‚Danielle' (*04.03.2020) verworfen — falsches Geschlecht für die Vaterrolle; ‚Hagrid Rubeus' verwendet." (Molly) - „⚠ Geschlecht weiblich aus ‚Wurfchronik.docx' verworfen — abweichend; männlich aus ‚Alberto Kids.xlsx' verwendet (Mehrheit)." (Solice) - „⚠ Vater ‚Jasper Jacob' (*2018) verworfen — unplausibel (7 Jahre älter); kein Ersatz." / DOB-Remap: „⚠ Geburtsdatum 22.08.2018 verworfen — per manueller Entscheidung korrigiert; 14.07.2019 verwendet." (Kazuya) Import: generischer _format_discard()-Helfer; Verwerfungen aus Mehrheitsentscheid, pick_parent_ref-Ablehnungen (explain_pick_rejections), Rollen-Normalisierung, Eltern-Alter-Sanity-Check und DOB-Remaps werden über _discarded/_prov_args an die Tiere gehängt; Gerbil-Provenance wird in einem finalen Pass gebaut (nachdem alle Verwerfungen feststehen). Frontend hebt ⚠-Zeilen ab (+ Legende). Tests grün (python merge/extract, vitest 129, playwright 8, dotnet 212). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
352 lines
15 KiB
Python
352 lines
15 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")))
|
|
|
|
|
|
# ── 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))
|
|
|
|
|
|
if check.failed:
|
|
print(f"\n{check.failed} test(s) FAILED")
|
|
sys.exit(1)
|
|
print("\nAll merge_and_resolve tests passed.")
|