Files
GerbilManager/tools/import/test_extract.py
Gulum a8d8ae0dfc conflict-decisions: correctDob remaps a wrong-birthdate duplicate before dedup
god added a `correctDob` (DD.MM.YYYY) decisions field: the matched (name+dob)
record is a DUPLICATE with a wrong birthdate → remap its DOB to correctDob so
dedup MERGES it into the canonical same-named animal. apply_dob_remaps runs
BEFORE dedup (it changes the dedup identity); tolerates a missing file; logged
as "DOB-Remaps: N". First use: Chelsea *15.10.2021 → *02.04.2021 (merges into
the canonical record). test_extract covers the remap + that both records then
share one name+dob identity.

Extractor-only. python + dotnet 121/121 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:14:23 +02:00

158 lines
7.3 KiB
Python

"""Zero-dep tests for extract.py band-aware Farbschlag + name-bleed guard.
Run: python test_extract.py (exit 0 = all pass)
Covers (PEDIGREE-LINK / Julian-confirmed): deep pedigree bands (gen >= 2, cols K/N/Q...)
are Name/DOB/Genotype ONLY — no Farbschlag cell — so a stray health note or the next
block's name must NOT be captured as Farbschlag; early bands (gen 0-1) keep their real
Farbschlag. Plus the looks_like_animal_name guard (a parent name must not be a Farbschlag).
"""
import os
import sys
import zipfile
import tempfile
import extract as e
failed = 0
def check(name, cond):
global failed
print(("ok: " if cond else "FAIL: ") + name)
if not cond:
failed += 1
def _cell(ref, text):
return f'<c r="{ref}" t="inlineStr"><is><t>{text}</t></is></c>'
def _make_xlsx(path, cells):
"""cells: {(colLetter+row): text}. Build a minimal single-sheet xlsx (no styles)."""
rows = {}
for ref, text in cells.items():
r = int("".join(ch for ch in ref if ch.isdigit()))
rows.setdefault(r, []).append(_cell(ref, text))
body = "".join(f'<row r="{r}">{"".join(cs)}</row>' for r, cs in sorted(rows.items()))
sheet = ('<?xml version="1.0"?><worksheet xmlns="http://x"><sheetData>'
+ body + "</sheetData></worksheet>")
with zipfile.ZipFile(path, "w") as z:
z.writestr("xl/worksheets/sheet1.xml", sheet)
# --- band-aware Farbschlag ---
# col E = gen 0 (early, HAS Farbschlag); col K = col 11 = gen 2 (deep, NO Farbschlag).
tmp = os.path.join(tempfile.gettempdir(), "bandtest.xlsx")
_make_xlsx(tmp, {
# early band (E): Name / *DOB / Farbschlag / Genotype
"E10": "Chesnut",
"E11": "*13.11.2019",
"E12": "Kohlfuchsschimmel",
"E13": "aa CC DD ee GG PP spsp rere",
# deep band (K): Name / *DOB / Genotype / stray NOTE (must NOT become Farbschlag)
"K10": "DeepAnimal",
"K11": "*01.01.2020",
"K12": "aa CC DD EE GG PP spsp rere",
"K13": "DD-Tumor",
})
try:
animals = e.extract_stammbaum(tmp)
by_name = {a["name"]: a for a in animals}
check("early band keeps real Farbschlag",
by_name.get("Chesnut", {}).get("farbschlag") == "Kohlfuchsschimmel")
check("deep band has NO Farbschlag (note not grabbed)",
by_name.get("DeepAnimal", {}).get("farbschlag") == "")
check("deep-band animal still parsed (Name/DOB/Genotype)",
"DeepAnimal" in by_name and by_name["DeepAnimal"]["dob"].startswith("01.01"))
finally:
try: os.remove(tmp)
except OSError: pass
gen = e.gen_of
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2)
# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")
import json as _json
_json.dump({"resolutions": [
{"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
"decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
"source": "test"},
{"name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
"decision": "Todesdatum 10.05.2021 (2022 war Tippfehler)", "dateOfDeath": "10.05.2021",
"source": "test"},
]}, open(dec_path, "w", encoding="utf-8"))
merged = [
{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
"conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}},
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
"conflict": True, "farbschlag": "", "death": "10.05.2022",
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
]
conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"},
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017"}]
n = e.apply_conflict_decisions(merged, conflicts, dec_path)
check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False)
check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True)
check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"])
check("decision dateOfDeath is authoritative (D5)", merged[1]["death"] == "10.05.2021")
check("decision removes both entries from conflicts list", conflicts == [])
check("apply_conflict_decisions returns resolved count", n == 2)
check("missing decisions file tolerated (returns 0)",
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
_json.dump({"resolutions": [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021",
"decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"},
]}, open(dec2, "w", encoding="utf-8"))
raw = [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"}, # the wrong-dob duplicate
{"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"}, # canonical
{"name": "Other Animal", "dob": "01.01.2020"},
]
rn = e.apply_dob_remaps(raw, dec2)
check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021")
check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021")
check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020")
check("apply_dob_remaps returns remap count", rn == 1)
check("after remap both Chelsea share one dedup identity (name+dob)",
e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"]))
check("missing decisions file tolerated for dob remaps (returns 0)",
e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0)
try: os.remove(dec2)
except OSError: pass
try: os.remove(dec_path)
except OSError: pass
# --- "presence wins" conflict rule (Julian) ---
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are.
check("spsp present vs locus absent -> no conflict",
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
check("DD vs D- (unknown vs filled) -> conflict",
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
check("Ee vs ee (different base allele) -> conflict",
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
check("C- vs Cc[h] -> conflict",
e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
check("c[h] vs c[chm] (different modifiers) -> conflict",
not e._alleles_compatible("c^h", "c^chm"))
check("identical genotypes -> no conflict",
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
# --- name-bleed guard (a parent name is not a Farbschlag) ---
check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten"))
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)
print("\nALL PASS")