Die Züchterin hat 24 neue/aktualisierte Stammbaum-xlsx geliefert (Ordner
"neuestammbäume"); sie liegen jetzt im kanonischen Quellverzeichnis
Sttammbäume (12 neue Charts, 7 aktualisierte, 3 identisch, das inhaltsgleiche
"Picus Son (2)" ausgelassen). Prod ist per Upload-Ingest aktualisiert:
2372 -> 2451 Tiere, 916 -> 965 Würfe, 432 -> 507 Fotos, 2198 -> 2275 Tiere
mit Geburtsdatum. Overrides/verified-Zeilen, manuelle Tiere und Tickets
haben den Ingest unverändert überlebt.
Zwei Datenfehler, die die neuen Charts aufgedeckt haben — datengetrieben und
re-ingest-stabil gefixt statt an der globalen Heuristik zu drehen:
- litterChildren kennt jetzt `add` [Name | {name, dob}] als Gegenstück zu
`keep`: hängt ein Jungtier an DIESEN Wurf und entfernt den alten Wurf, wenn
er dadurch kinderlos UND virtuell ist. Nötig, weil "Pukas Kids" Akanes
Eltern komplett UNTER ihren Block setzt (N80 Roni = Vater, N81 Fumi =
Mutter) — _reconstruct_parents griff eine Zeile zu hoch, paarte Irish
Coffee (Bonapartes Mutter) mit Roni und riss Akane aus dem Z21-Wurf in
einen Phantom-Wurf, der in der Wurfchronik auftauchte (Ticket 88389f8e).
- Merle: durch das neue Geburtsdatum (18.06.2023) mergt der addAnimals-Stub
in den Chart-Datensatz und verliert dabei sein isResident -> expliziter
resolutions-Override (Ticket 36a3fcde/a8f11ac0, Züchterin: Zuchttier).
Außerdem: Excel legt neben Fotos teils EMF/WMF-Vektorvorschauen ab, die
Browser nicht darstellen können (kaputte Bildkachel in der Tier-Akte) ->
extract._attach_photos überspringt .emf/.wmf (5 Fotos betroffen).
Regressionstests für alle drei Punkte; alle Python-Suites grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
505 lines
27 KiB
Python
505 lines
27 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.")
|
|
|
|
if failed:
|
|
print(f"\n{failed} test(s) FAILED")
|
|
sys.exit(1)
|
|
print("\nALL PASS")
|