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>
556 lines
29 KiB
Python
556 lines
29 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 B, E, H", gen(2) == 0 and gen(5) == 1 and gen(8) == 2)
|
|
check("gen_of: deep bands K, N, Q", gen(11) == 3 and gen(14) == 4 and gen(17) == 5)
|
|
|
|
# --- 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)
|
|
|
|
# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
|
|
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
|
|
_json.dump({"resolutions": [
|
|
{"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den'
|
|
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
|
]}, open(dec_vd, "w", encoding="utf-8"))
|
|
merged_vd = [
|
|
{"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.'
|
|
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
|
]
|
|
conflicts_vd = [{"id": "vw"}]
|
|
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
|
|
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
|
|
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
|
|
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
|
|
_json.dump({"resolutions": [
|
|
{"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision
|
|
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
|
]}, open(dec_vd, "w", encoding="utf-8"))
|
|
merged_vd2 = [
|
|
{"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den'
|
|
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
|
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
|
]
|
|
conflicts_vd2 = [{"id": "vw2"}]
|
|
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
|
|
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
|
|
try: os.remove(dec_vd)
|
|
except OSError: pass
|
|
|
|
# FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation)
|
|
dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json")
|
|
_json.dump({"resolutions": [
|
|
# Decision only for Luna from ZdkC, NOT Luna from Black Forest
|
|
{"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
|
"decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"},
|
|
]}, open(dec_c3, "w", encoding="utf-8"))
|
|
merged_c3 = [
|
|
{"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
|
"conflict": True, "farbschlag": "", "death": "",
|
|
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
|
{"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020",
|
|
"conflict": True, "farbschlag": "", "death": "",
|
|
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
|
]
|
|
conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}]
|
|
n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3)
|
|
check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1)
|
|
check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False)
|
|
check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True)
|
|
check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf")
|
|
try: os.remove(dec_c3)
|
|
except OSError: pass
|
|
|
|
# --- 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" + "specific wins" conflict rules (Julian) ---
|
|
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are.
|
|
# FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict —
|
|
# the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP).
|
|
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"]}]))
|
|
# FIX-2: '?' vs specified = specific wins (was: contradiction)
|
|
check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict",
|
|
not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
|
check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict",
|
|
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
|
check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict",
|
|
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}]))
|
|
check("FIX-2: G- vs Gg (specific wins) -> NOT conflict",
|
|
not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}]))
|
|
check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict",
|
|
not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}]))
|
|
# Genuine value contradictions (both alleles specified but different) still quarantine
|
|
check("Ee vs ee (different base allele) -> conflict",
|
|
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
|
|
check("DD vs Dd (both specified, D vs d) -> conflict",
|
|
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
|
|
check("PP vs Pp (both specified) -> conflict",
|
|
e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}]))
|
|
check("c[h] vs c[chm] (different modifiers, both specified) -> conflict",
|
|
not e._alleles_compatible("c^h", "c^chm"))
|
|
check("identical genotypes -> no conflict",
|
|
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
|
|
|
|
# FIX-2 MERGE: specific allele must survive the merge regardless of which variant comes first.
|
|
# dedup() picks the most specific genotype (fewest '?' alleles); C- vs CC -> CC must win.
|
|
def _minimal_animal(name, dob, mapped):
|
|
"""Build a minimal raw animal dict suitable for dedup()."""
|
|
from genotype import parse as gparse
|
|
raw = " ".join(f"{l}{''.join(a)}" for l, pa in mapped.items() for a in [pa])
|
|
return {
|
|
"name": name, "dob": dob, "death": "", "gender": None,
|
|
"farbschlag": "", "breeder": "", "zucht": "", "parentRefs": [],
|
|
"photos": [], "sourceFiles": ["test.xlsx"], "tags": [],
|
|
"deaf": None, "conflict": False,
|
|
"genotype": {"mapped8locus": mapped, "rawGenotype": raw, "unmappedTokens": []},
|
|
"_gen": 0, "_col": 5, "_row": 10, "_file": "test.xlsx",
|
|
"_zucht": "",
|
|
}
|
|
|
|
# Order A: C- first, CC second
|
|
animals_merge_a = [
|
|
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "?"]}), # C-
|
|
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "C"]}), # CC
|
|
]
|
|
merged_ma, _, _, _ = e.dedup(animals_merge_a)
|
|
check("FIX-2 merge A (C- first): result has CC not C-",
|
|
merged_ma[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
|
|
|
|
# Order B: CC first, C- second (must give same result)
|
|
animals_merge_b = [
|
|
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "C"]}), # CC
|
|
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "?"]}), # C-
|
|
]
|
|
merged_mb, _, _, _ = e.dedup(animals_merge_b)
|
|
check("FIX-2 merge B (CC first): result has CC not C-",
|
|
merged_mb[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
|
|
|
|
# G- vs Gg: Gg must win
|
|
animals_merge_g = [
|
|
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "?"]}), # G-
|
|
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "g"]}), # Gg
|
|
]
|
|
merged_mg, _, _, _ = e.dedup(animals_merge_g)
|
|
check("FIX-2 merge G (G- vs Gg): Gg wins",
|
|
merged_mg[0]["genotype"]["mapped8locus"].get("G") == ["G", "g"])
|
|
|
|
# --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured ---
|
|
dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018")
|
|
check("FIX-4: '/ +YEAR' artifact stripped from geno tail",
|
|
geno4 == "aa C- DD ee Gg PP spsp rere")
|
|
check("FIX-4: death year still captured from full cell text",
|
|
death4 == "2018")
|
|
check("FIX-4: DOB still correct",
|
|
dob4 == "17.04.2016")
|
|
# Without artifact — must be unchanged
|
|
dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere")
|
|
check("FIX-4: no artifact -> geno unchanged",
|
|
geno5 == "aa C- DD ee Gg PP spsp rere")
|
|
check("FIX-4: no artifact -> no spurious death",
|
|
death5 == "")
|
|
|
|
# --- FIX (ab8fdb0b): leading death-marker right after DOB must not leak into geno ---
|
|
# bare "/+," (Mamono) — death without date
|
|
_, _, geno_m = e.parse_detail("Mamono,*02.08.2020/+, Aa C- D- Ee gg PP spsp")
|
|
check("FIX-ab8fdb0b: bare '/+,' stripped from geno (Mamono)",
|
|
geno_m == "Aa C- D- Ee gg PP spsp")
|
|
# "/+<Textmonth>'<year>" (Talula) — text-month death marker
|
|
_, _, geno_t = e.parse_detail("Talula,*01.01.2015/+April'2017, aa C- DD ee GG PP spsp")
|
|
check("FIX-ab8fdb0b: '/+Textmonth'year' stripped from geno (Talula)",
|
|
geno_t == "aa C- DD ee GG PP spsp")
|
|
# leading "+<numeric date>" (Fegur) — death date captured, geno clean
|
|
dob_f, death_f, geno_f = e.parse_detail("Fegur,*01.01.2016+09.07.2017, aa CC DD ee GG PP spsp")
|
|
check("FIX-ab8fdb0b: leading '+date' stripped from geno (Fegur)",
|
|
geno_f == "aa CC DD ee GG PP spsp")
|
|
check("FIX-ab8fdb0b: leading '+date' still captured as death (Fegur)",
|
|
death_f == "09.07.2017")
|
|
# "/+ ," (Flippi) — death marker with trailing comma, no date
|
|
_, _, geno_fl = e.parse_detail("Flippi,*01.01.2016/+ , aa CC DD ee GG PP spsp")
|
|
check("FIX-ab8fdb0b: '/+ ,' stripped from geno (Flippi)",
|
|
geno_fl == "aa CC DD ee GG PP spsp")
|
|
|
|
# --- 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"))
|
|
|
|
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
|
|
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
|
|
_json.dump({"resolutions": [
|
|
# Valid decision (genotype parses OK) -> should be applied
|
|
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
|
|
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
|
|
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
|
|
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
|
|
"genotype": "BLÖDSINN!!!", "source": "test"},
|
|
]}, open(dec_cr10, "w", encoding="utf-8"))
|
|
merged_cr10 = [
|
|
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
|
|
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
|
|
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
|
|
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
|
|
]
|
|
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
|
|
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
|
|
check("CR-10: valid decision genotype is applied (A-locus updated)",
|
|
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
|
|
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
|
|
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
|
|
check("CR-10: malformed decision still un-quarantines the animal",
|
|
merged_cr10[1].get("conflict") is False)
|
|
check("CR-10: malformed decision adds a decisionWarning",
|
|
bool(merged_cr10[1].get("decisionWarnings")))
|
|
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
|
|
try: os.remove(dec_cr10)
|
|
except OSError: pass
|
|
|
|
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
|
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
|
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
|
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
|
|
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
|
|
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
|
|
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
|
|
e.is_clan_zucht("kleinen Chaoten"))
|
|
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
|
|
e.is_clan_zucht("v.d. Kleinen Chaoten"))
|
|
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
|
|
e.is_clan_zucht("ZdkC"))
|
|
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
|
|
e.is_clan_zucht("von den Kleinen Chaoten"))
|
|
check("KC-matcher: empty string -> NOT clan",
|
|
not e.is_clan_zucht(""))
|
|
check("KC-matcher: 'Black Forest' -> NOT clan",
|
|
not e.is_clan_zucht("Black Forest"))
|
|
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
|
|
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
|
|
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
|
|
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
|
|
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
|
|
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
|
|
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
|
|
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
|
|
|
|
# --- Stammbaum von Danako validation ---
|
|
danako_path = r"C:\Users\gulum\dev\Wurfchronik_Bilder\Stammbaum von Danako.xlsx"
|
|
if not os.path.exists(danako_path):
|
|
danako_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Danako.xlsx"
|
|
|
|
if os.path.exists(danako_path):
|
|
print(f"\nFound Danako stammbaum at {danako_path}, running integration validation...")
|
|
danako_animals = e.extract_stammbaum(danako_path)
|
|
danako_by_name = {a["name"]: a for a in danako_animals}
|
|
|
|
check("Danako present in Danako sheet", "Danako" in danako_by_name)
|
|
if "Danako" in danako_by_name:
|
|
d = danako_by_name["Danako"]
|
|
check("Danako DOB is 22.08.2018", d["dob"] == "22.08.2018")
|
|
check("Danako photo matches image7.png", d["photos"] == ["photos/danako-22082018/image7.png"])
|
|
|
|
check("Osamu present in Danako sheet", "Osamu" in danako_by_name)
|
|
if "Osamu" in danako_by_name:
|
|
o = danako_by_name["Osamu"]
|
|
check("Osamu DOB is 10.12.2015", o["dob"] == "10.12.2015")
|
|
check("Osamu photo matches image4.jpeg", o["photos"] == ["photos/osamu-10122015/image4.jpeg"])
|
|
|
|
# Check parentRefs of Osamu in Danako sheet
|
|
o_parents = o.get("parentRefs", [])
|
|
o_father = next((p for p in o_parents if p.get("roleGuess") == "father"), None)
|
|
o_mother = next((p for p in o_parents if p.get("roleGuess") == "mother"), None)
|
|
check("Osamu father is Porter", o_father is not None and o_father["name"] == "Porter")
|
|
check("Osamu mother is Yuka", o_mother is not None and o_mother["name"] == "Yuka")
|
|
if o_father:
|
|
check("Osamu father DOB is 23.05.2015", o_father["dob"] == "23.05.2015")
|
|
if o_mother:
|
|
check("Osamu mother DOB is 12.07.2015", o_mother["dob"] == "12.07.2015")
|
|
|
|
check("Porter present in Danako sheet", "Porter" in danako_by_name)
|
|
if "Porter" in danako_by_name:
|
|
p = danako_by_name["Porter"]
|
|
check("Porter DOB is 23.05.2015", p["dob"] == "23.05.2015")
|
|
check("Porter photo matches image6.jpeg", p["photos"] == ["photos/porter-23052015/image6.jpeg"])
|
|
|
|
check("Yuka present in Danako sheet", "Yuka" in danako_by_name)
|
|
if "Yuka" in danako_by_name:
|
|
y = danako_by_name["Yuka"]
|
|
check("Yuka DOB is 12.07.2015", y["dob"] == "12.07.2015")
|
|
check("Yuka photo matches image5.jpeg", y["photos"] == ["photos/yuka-12072015/image5.jpeg"])
|
|
|
|
check("Eddward present in Danako sheet", "Eddward" in danako_by_name)
|
|
if "Eddward" in danako_by_name:
|
|
ed = danako_by_name["Eddward"]
|
|
check("Eddward DOB is 18.11.2015", ed["dob"] == "18.11.2015")
|
|
check("Eddward photo matches image1.jpeg", ed["photos"] == ["photos/eddward-18112015/image1.jpeg"])
|
|
|
|
check("Harumi present in Danako sheet", "Harumi" in danako_by_name)
|
|
if "Harumi" in danako_by_name:
|
|
h = danako_by_name["Harumi"]
|
|
check("Harumi DOB is 21.02.2015", h["dob"] == "21.02.2015")
|
|
check("Harumi photo matches image2.jpeg", h["photos"] == ["photos/harumi-21022015/image2.jpeg"])
|
|
else:
|
|
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
|
|
|
|
# --- Stammbaum von Kazuya: photo generation-shift regression (PHOTO-LEFT-STYLE) ---
|
|
# This sheet has neither col-1 nor col-4 image anchors, so the old fixed-column
|
|
# heuristic misread it as right-style and shifted every photo one generation
|
|
# toward the proband: Kazuya wore his father's (Wilbur's) photo, Wilbur wore the
|
|
# grandfather's (Elay's). The fix picks the layout that places photos closest to
|
|
# their animal's name column → photos land on the correct generation.
|
|
kazuya_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Kazuya.xlsx"
|
|
if os.path.exists(kazuya_path):
|
|
print(f"\nFound Kazuya stammbaum, running photo generation-shift validation...")
|
|
kz = {a["name"]: a for a in e.extract_stammbaum(kazuya_path)}
|
|
if "Kazuya" in kz:
|
|
check("Kazuya (proband) has NO photo of his own", kz["Kazuya"]["photos"] == [])
|
|
if "Wilbur" in kz:
|
|
check("Wilbur (father) gets his own photo (image7), not the grandfather's",
|
|
kz["Wilbur"]["photos"] == ["photos/wilbur-19032017/image7.jpeg"])
|
|
if "Elay" in kz:
|
|
check("Elay (grandfather) gets his own photo (image2)",
|
|
kz["Elay"]["photos"] == ["photos/elay-16032016/image2.jpeg"])
|
|
else:
|
|
print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.")
|
|
|
|
# --- External-origin founders get NO fabricated chart-position parents -------
|
|
# Tickets #5 (Bill von Privat), #13 (Cooky vom Zooladen), #28 (Zadar from … Croatia):
|
|
# pet-shop / private / foreign-import animals have genuinely unknown ancestry, so
|
|
# _reconstruct_parents must not invent parents for them from neighbouring blocks.
|
|
check("is_external_origin: 'von Privat' name", e.is_external_origin("Bill von Privat"))
|
|
check("is_external_origin: 'vom Zooladen (OBI)' name",
|
|
e.is_external_origin("Cooky vom Zooladen (OBI)"))
|
|
check("is_external_origin: foreign 'from …, Croatia'",
|
|
e.is_external_origin("Zadar from Zeko i ptica, Croatia"))
|
|
check("is_external_origin: clan animal is NOT external",
|
|
not e.is_external_origin("Silver von den kleinen Chaoten", "Kleine Chaoten"))
|
|
check("is_external_origin: ordinary cattery 'of Black Forest' is NOT external",
|
|
not e.is_external_origin("Hagrid Rubeus of Black Forest", "Black Forest"))
|
|
|
|
# _reconstruct_parents must skip the external leaf but still parent the clan child.
|
|
_ext_animals = [
|
|
{"name": "Kind von den Kleinen Chaoten", "_gen": 0, "_row": 5, "dob": "01.01.2020",
|
|
"zucht": "Kleine Chaoten", "breeder": "", "gender": None, "parentRefs": []},
|
|
{"name": "Cooky vom Zooladen (OBI)", "_gen": 1, "_row": 4, "dob": "01.01.2018",
|
|
"zucht": "", "breeder": "", "gender": "female", "parentRefs": []},
|
|
{"name": "Papa von den Kleinen Chaoten", "_gen": 1, "_row": 6, "dob": "01.01.2017",
|
|
"zucht": "Kleine Chaoten", "breeder": "", "gender": "male", "parentRefs": []},
|
|
{"name": "OmaUnbekannt", "_gen": 2, "_row": 3, "dob": "", "zucht": "", "breeder": "",
|
|
"gender": None, "parentRefs": []},
|
|
]
|
|
e._reconstruct_parents(_ext_animals)
|
|
_cooky = next(a for a in _ext_animals if a["name"].startswith("Cooky"))
|
|
check("_reconstruct_parents: external Cooky gets NO parentRefs",
|
|
_cooky["parentRefs"] == [])
|
|
_kind = next(a for a in _ext_animals if a["name"].startswith("Kind"))
|
|
check("_reconstruct_parents: clan child still gets its chart-position parents",
|
|
len(_kind["parentRefs"]) >= 1)
|
|
|
|
# --- gender override (conflict-decisions) — Mozart misread male, should be female ---
|
|
_g_merged = [{"id": "moz", "name": "Mozart of Lennylengo", "dob": "12.03.2017",
|
|
"gender": "male", "genotype": e.gt.parse(""), "parentRefs": [],
|
|
"conflict": False}]
|
|
_g_dec = os.path.join(tempfile.gettempdir(), "conflict-gender-test.json")
|
|
import json as _json
|
|
_json.dump({"resolutions": [
|
|
{"name": "Mozart of Lennylengo", "dob": "12.03.2017", "gender": "female",
|
|
"decision": "box colour misread"}
|
|
]}, open(_g_dec, "w", encoding="utf-8"))
|
|
e.apply_conflict_decisions(_g_merged, [], _g_dec)
|
|
check("gender override: Mozart flipped male -> female", _g_merged[0]["gender"] == "female")
|
|
try: os.remove(_g_dec)
|
|
except OSError: pass
|
|
|
|
# --- EMF/WMF-Vorschauen sind keine Fotos (neue Stammbäume 2026-08-18) ---
|
|
# Excel legt neben dem echten Foto teils ein Vektor-Metafile ab; Browser können es nicht
|
|
# darstellen → es landete als kaputte Bildkachel in der Tier-Akte.
|
|
_emf_chart = os.path.join(r"C:\Users\gulum\dev\Sttammbäume",
|
|
"Stammbaum von Kohlief und Johnny Jumper Kids.xlsx")
|
|
if os.path.exists(_emf_chart):
|
|
_emf_animals = e.extract_stammbaum(_emf_chart)
|
|
_emf_photos = [ph for a in _emf_animals for ph in a["photos"]
|
|
if ph.lower().endswith((".emf", ".wmf"))]
|
|
check("EMF/WMF werden nicht als Foto angehängt", _emf_photos == [])
|
|
else:
|
|
print("\nWarning: EMF-Stammbaum nicht gefunden, EMF-Check übersprungen.")
|
|
|
|
# --- sternloses Geburtsdatum im Block (Ticket 65266679 Bijou) ---
|
|
# Einige Charts vergessen den Stern ("22.08.2019/+09.04.2024"); ohne Stern erkannte die
|
|
# Blockheuristik das Tier gar nicht und _reconstruct_parents griff in den Nachbar-Ast.
|
|
# Normalisiert wird NUR mit Block-Kontext (Genotyp darunter UND Name darueber), damit
|
|
# beliebige Datumszellen nicht faelschlich zu Tieren werden.
|
|
_tmp_star = os.path.join(tempfile.gettempdir(), "starless_dob.xlsx")
|
|
_make_xlsx(_tmp_star, {
|
|
# echter Block, aber ohne Stern vor dem Geburtsdatum (Quelldatei-Tippfehler)
|
|
"K44": "Louis of Black Forest",
|
|
"K45": "22.08.2019/+09.04.2024",
|
|
"K46": "Aa CC dd EE Gg P- spsp",
|
|
# Datum mit Name darueber, aber OHNE Genotyp darunter -> kein Block
|
|
"N44": "Irgendeine Notiz",
|
|
"N45": "01.01.2020",
|
|
# Datum mit Genotyp darunter, aber OHNE Namenszelle darueber -> kein Block
|
|
"Q45": "02.02.2021",
|
|
"Q46": "aa CC DD EE GG PP spsp",
|
|
})
|
|
try:
|
|
_star_animals = e.extract_stammbaum(_tmp_star)
|
|
_star_by_name = {a["name"]: a for a in _star_animals}
|
|
check("sternloses DOB wird als Block erkannt", "Louis of Black Forest" in _star_by_name)
|
|
check("sternloses DOB: Geburts- und Todesdatum geparst",
|
|
_star_by_name.get("Louis of Black Forest", {}).get("dob") == "22.08.2019"
|
|
and _star_by_name.get("Louis of Black Forest", {}).get("death") == "09.04.2024")
|
|
check("Datumszelle ohne Block-Kontext wird NICHT zum Tier",
|
|
len(_star_animals) == 1)
|
|
finally:
|
|
try: os.remove(_tmp_star)
|
|
except OSError: pass
|
|
|
|
# Direkt auf der Normalisierung: Todesdatum-only-Zellen ("/+01.09.2024", kommt in
|
|
# "Stammbaum von Kentucky/Watarus Kids" vor) duerfen NIE zum Geburtsdatum werden.
|
|
_cells = {
|
|
(11, 45): "22.08.2019/+09.04.2024", # Block -> bekommt den Stern
|
|
(11, 44): "Louis of Black Forest",
|
|
(11, 46): "Aa CC dd EE Gg P- spsp",
|
|
(14, 66): "/+01.09.2024", # nur Todesdatum -> unangetastet
|
|
(14, 65): "Nani of Black Forest",
|
|
(14, 67): "Aa CC dd EE Gg P- spsp",
|
|
(17, 30): "30.01.2020/+", # Block, Todesdatum leer -> bekommt den Stern
|
|
(17, 29): "Jiminy of Black Forest",
|
|
(17, 31): "Aa Cc[chm] Dd ee gg P- Spsp",
|
|
}
|
|
e._normalise_starless_dobs(_cells)
|
|
check("Normalisierung setzt den Stern im Block", _cells[(11, 45)] == "*22.08.2019/+09.04.2024")
|
|
check("Normalisierung setzt den Stern auch bei leerem Todesdatum",
|
|
_cells[(17, 30)] == "*30.01.2020/+")
|
|
check("Normalisierung laesst reine Todesdatum-Zellen unberuehrt",
|
|
_cells[(14, 66)] == "/+01.09.2024")
|
|
|
|
if failed:
|
|
print(f"\n{failed} test(s) FAILED")
|
|
sys.exit(1)
|
|
print("\nALL PASS")
|