fix(import): Stammbaum-/Eltern-/isResident-Tickets der Züchterin (18)
Import-Logik: - Externe Gründer (Zooladen/„von Privat"/„from …, <Land>") bekommen keine erfundenen Chart-Eltern mehr → Bill, Cooky, Zadar haben korrekt unbekannte Eltern (is_external_origin). [#5,#13,#28] - Hagrid: externe Zucht wird nicht mehr als Bestand markiert (isResident=false) + Leerhüllen-Dedup → ein Datensatz mit Eltern Snickers × Milka. [#17,#18,#20a] - Gender-Index: eindeutiges Geschlecht schlägt unbekanntes Duplikat → Rollen- Auflösung repariert (Vance→Mutter Enya, Zac→Vater Vance/Mutter Dorie). [#33,#35] - „Eltern: X + Y"-Wurfnotizen werden geparst (~46 Würfe); v.d.↔von-den-Namens- kanon (Theodore→BlackFire). [#9,#11,#30] Daten-Overrides (conflict-decisions.json, jetzt auch Gender + exakte Eltern): - Mozart→weiblich [#2], Yuki=Camaro×Izumi [#23], Gold-Mutter=Chelsea [#36], Arya=Vance×Sansa (Geschwisterverpaarung) [#16], Tony→Sammy [#12], Odelia [#15], Jamie→Danny [#31], Silver=Taro×Beatrice [#11]. Frontend: GerbilDetailPage blendet für isResident=false Würfe + Charakter aus (Hinweis nonResidentNote). [#17,#20b] Tests: test_extract/test_merge_resolve erweitert; vitest 135, playwright tiere 34 grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,24 @@ test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF
|
|||||||
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Detailseite: externer Vorfahre (isResident=false) blendet Würfe & Charakter aus (#17/#20)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// 'hilde' ist isResident=false (externer Ahne, nur für den Stammbaum erfasst).
|
||||||
|
await page.goto('/rennmaeuse/hilde')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Hilde' })).toBeVisible()
|
||||||
|
// Extern-Badge + Hinweis sichtbar, Charakter- und Würfe-Abschnitt ausgeblendet.
|
||||||
|
await expect(page.getByText(t.detail.nonResidentNote)).toBeVisible()
|
||||||
|
await expect(page.getByText(de.character.sectionTitle)).toBeHidden()
|
||||||
|
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeHidden()
|
||||||
|
|
||||||
|
// Gegenprobe: Bestandstier Krümel zeigt beide Abschnitte.
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
|
await expect(page.getByText(de.character.sectionTitle)).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.nonResidentNote)).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
|
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse')
|
await page.goto('/rennmaeuse')
|
||||||
|
|||||||
@@ -357,6 +357,15 @@ export default function GerbilDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Externe Vorfahren waren nie im eigenen Bestand → Würfe & Charakter
|
||||||
|
sind für sie nicht relevant; statt der Abschnitte ein kurzer Hinweis. */}
|
||||||
|
{g.isResident === false && (
|
||||||
|
<section className="ak-card">
|
||||||
|
<p className="ak-empty">{t.detail.nonResidentNote}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{g.isResident !== false && (
|
||||||
<section className="ak-card">
|
<section className="ak-card">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -427,7 +436,9 @@ export default function GerbilDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{g.isResident !== false && (
|
||||||
<section className="ak-card">
|
<section className="ak-card">
|
||||||
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
|
<h2 className="ak-h2">{t.detail.parentLittersTitle}</h2>
|
||||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
@@ -457,6 +468,7 @@ export default function GerbilDetailPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="ak-card">
|
<section className="ak-card">
|
||||||
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
<h2 className="visually-hidden">{t.detail.moreData}</h2>
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ export const de = {
|
|||||||
siblingPairing: 'Geschwisterverpaarung',
|
siblingPairing: 'Geschwisterverpaarung',
|
||||||
siblingPairingTitle:
|
siblingPairingTitle:
|
||||||
'Die Eltern dieses Tiers stammen aus demselben Wurf (Vollgeschwister).',
|
'Die Eltern dieses Tiers stammen aus demselben Wurf (Vollgeschwister).',
|
||||||
|
// Externe Tiere (nur als Vorfahre erfasst): Würfe & Charakter werden
|
||||||
|
// ausgeblendet, da das Tier nie im eigenen Bestand war.
|
||||||
|
nonResidentNote:
|
||||||
|
'Externes Tier — nur als Vorfahre erfasst. Würfe und Charakter werden nicht angezeigt, da es nie im eigenen Bestand war.',
|
||||||
},
|
},
|
||||||
// Formular (anlegen/bearbeiten)
|
// Formular (anlegen/bearbeiten)
|
||||||
form: {
|
form: {
|
||||||
|
|||||||
@@ -265,6 +265,69 @@
|
|||||||
"father": "Osamu von den Kleinen Chaoten",
|
"father": "Osamu von den Kleinen Chaoten",
|
||||||
"mother": "Montana v.d. Kleinen Chaoten",
|
"mother": "Montana v.d. Kleinen Chaoten",
|
||||||
"source": "Stammbaum von Kazuya.xlsx"
|
"source": "Stammbaum von Kazuya.xlsx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mozart of Lennylengo",
|
||||||
|
"dob": "12.03.2017",
|
||||||
|
"decision": "gender = female (box colour in Stammbaum von Fire Kids.xlsx was misread as male). Mozart is the mother of Stich von Privatzucht Giessen's litter.",
|
||||||
|
"gender": "female",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #2 (Stich, Mutter fehlte)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Yuki von den Kleinen Chaoten",
|
||||||
|
"dob": "03.02.2020",
|
||||||
|
"decision": "parents are Camaro (Chevrolet Camaro of Topolino) × Izumi, NOT Benjiro + Louis (chart-position misread).",
|
||||||
|
"father": "Chevrolet Camaro of Topolino",
|
||||||
|
"mother": "Izumi von den Kleinen Chaoten",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold v.d. Kleinen Chaoten",
|
||||||
|
"dob": "03.08.2023",
|
||||||
|
"decision": "mother should be Chelsea von den Kleinen Chaoten (not Gaida — chart-position misread). Father Trogir confirmed via contract 'Platin (Trogir.Chelsea)'.",
|
||||||
|
"father": "Trogir von den Kleinen Chaoten",
|
||||||
|
"mother": "Chelsea von den Kleinen Chaoten",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #36"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Arya Stark von den Kleinen Chaoten",
|
||||||
|
"dob": "30.06.2020",
|
||||||
|
"decision": "parents are Sansa Stark (mother) × Vance (father) — a sibling pairing (both *09.04.2019). Chart-position had scrambled the roles (mother=Enya, no father).",
|
||||||
|
"father": "Vance von den Kleinen Chaoten",
|
||||||
|
"mother": "Sansa Stark von den Kleinen Chaoten",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tony v.d. Kleinen Chaoten",
|
||||||
|
"dob": "16.09.2015",
|
||||||
|
"decision": "father is Sammy von den Kleinen Chaoten (Wurfchronik L7-Wurf: 'Parents Sammy + Queenie'). Chart-position had fabricated 'Jack Jr.'.",
|
||||||
|
"father": "Sammy von den Kleinen Chaoten",
|
||||||
|
"mother": "Qamikaze Queen von den Schlossmäusen",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Odelia von den Kleinen Chaoten",
|
||||||
|
"dob": "21.06.2015",
|
||||||
|
"decision": "parents are Cash × Elena (Wurfchronik O6-Wurf). Chart-position had wrong parents (Chap × Julietta).",
|
||||||
|
"father": "Cash v.d. Kleinen Chaoten",
|
||||||
|
"mother": "Elena",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jamie von den kleinen Chaoten",
|
||||||
|
"dob": "27.12.2010",
|
||||||
|
"decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.",
|
||||||
|
"father": "Danny von PZ Maintal",
|
||||||
|
"mother": "Jana of little longnoses",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silver von den kleinen Chaoten",
|
||||||
|
"dob": "20.03.2013",
|
||||||
|
"decision": "parents are Taro (kept from U1-Wurf) × Beatrice (Wurfchronik F2-Wurf). Cross-page chart reference to Taro was unresolvable.",
|
||||||
|
"father": "Taro von den kleinen Chaoten",
|
||||||
|
"mother": "Beatrice von den kleinen Chaoten",
|
||||||
|
"source": "Züchterin 2026-06-22 — Ticket #11"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,38 @@ def is_clan_zucht(z):
|
|||||||
return norm_zucht(z) == "kleinechaote"
|
return norm_zucht(z) == "kleinechaote"
|
||||||
|
|
||||||
|
|
||||||
|
# Name/breeder markers for an externally-acquired animal whose ancestry is NOT
|
||||||
|
# in the breeder's own charts: pet shops, "von Privat" private hobbyists and
|
||||||
|
# foreign catteries ("from … , Croatia"). Such founders have genuinely UNKNOWN
|
||||||
|
# parents — the chart-position heuristic must not fabricate parents for them.
|
||||||
|
_EXTERNAL_MARKERS = re.compile(
|
||||||
|
r"\b(zooladen|zoohandlung|obi|fressnapf|dehner|von\s+privat|privatkauf|"
|
||||||
|
r"vom\s+bauern|aus\s+der\s+zoohandlung)\b", re.IGNORECASE)
|
||||||
|
# Foreign acquisition: "<name> from <cattery>, <Country>" (a comma + country word).
|
||||||
|
_FOREIGN_FROM = re.compile(
|
||||||
|
r"\bfrom\b.+,\s*(croatia|kroatien|poland|polen|netherlands|niederlande|"
|
||||||
|
r"belgium|belgien|france|frankreich|austria|österreich|switzerland|schweiz|"
|
||||||
|
r"italy|italien|spain|spanien|czech|tschechien|hungary|ungarn)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_external_origin(name, zucht=None, breeder=None):
|
||||||
|
"""True if an animal is an externally-acquired founder with genuinely unknown
|
||||||
|
ancestry (pet shop, private hobbyist, foreign cattery). For such animals the
|
||||||
|
positional chart heuristic must NOT invent parents — their parents are unknown.
|
||||||
|
|
||||||
|
NOT external: own stock ('… von den Kleinen Chaoten') and ordinary German
|
||||||
|
catteries that appear as in-chart ancestors (e.g. 'of Black Forest') — those
|
||||||
|
can legitimately have charted ancestors elsewhere; this gate is only for the
|
||||||
|
leaf/founder markers above.
|
||||||
|
"""
|
||||||
|
blob = " ".join(p for p in (name, zucht, breeder) if p)
|
||||||
|
if _EXTERNAL_MARKERS.search(blob):
|
||||||
|
return True
|
||||||
|
if _FOREIGN_FROM.search(name or ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def canon_pair(raw):
|
def canon_pair(raw):
|
||||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||||
name, zucht = split_name_zucht(raw)
|
name, zucht = split_name_zucht(raw)
|
||||||
@@ -319,6 +351,11 @@ def _reconstruct_parents(animals):
|
|||||||
if not nxt:
|
if not nxt:
|
||||||
continue
|
continue
|
||||||
for a in group:
|
for a in group:
|
||||||
|
# Externally-acquired founders (pet shop / von Privat / foreign cattery)
|
||||||
|
# have genuinely UNKNOWN ancestry — do NOT invent chart-position parents
|
||||||
|
# for them (tickets #5 Bill von Privat, #13 Cooky vom Zooladen, #28 Zadar).
|
||||||
|
if is_external_origin(a.get("name"), a.get("zucht"), a.get("breeder")):
|
||||||
|
continue
|
||||||
r = a["_row"]
|
r = a["_row"]
|
||||||
above = [x for x in nxt if x["_row"] <= r]
|
above = [x for x in nxt if x["_row"] <= r]
|
||||||
below = [x for x in nxt if x["_row"] > r]
|
below = [x for x in nxt if x["_row"] > r]
|
||||||
@@ -1103,6 +1140,12 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
a["farbschlagVariants"] = [d["farbschlag"]]
|
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||||
if d.get("dateOfDeath"): # D5 death-date resolutions
|
if d.get("dateOfDeath"): # D5 death-date resolutions
|
||||||
a["death"] = norm_dob(d["dateOfDeath"])
|
a["death"] = norm_dob(d["dateOfDeath"])
|
||||||
|
if d.get("gender"): # gender override (box colour misread)
|
||||||
|
g = d["gender"].strip().lower()
|
||||||
|
g = {"m": "male", "männlich": "male", "w": "female",
|
||||||
|
"f": "female", "weiblich": "female"}.get(g, g)
|
||||||
|
if g in ("male", "female"):
|
||||||
|
a["gender"] = g
|
||||||
if "father" in d or "mother" in d:
|
if "father" in d or "mother" in d:
|
||||||
new_refs = []
|
new_refs = []
|
||||||
if d.get("father"):
|
if d.get("father"):
|
||||||
|
|||||||
@@ -117,6 +117,40 @@ def normalize_name(name):
|
|||||||
return ""
|
return ""
|
||||||
return "".join(c for c in name.lower() if c.isalnum())
|
return "".join(c for c in name.lower() if c.isalnum())
|
||||||
|
|
||||||
|
|
||||||
|
_EXTERNAL_MARKERS_RE = re.compile(
|
||||||
|
r"\b(zooladen|zoohandlung|obi|fressnapf|dehner|von\s+privat|privatkauf|"
|
||||||
|
r"vom\s+bauern|aus\s+der\s+zoohandlung)\b", re.IGNORECASE)
|
||||||
|
_FOREIGN_FROM_RE = re.compile(
|
||||||
|
r"\bfrom\b.+,\s*(croatia|kroatien|poland|polen|netherlands|niederlande|"
|
||||||
|
r"belgium|belgien|france|frankreich|austria|österreich|switzerland|schweiz|"
|
||||||
|
r"italy|italien|spain|spanien|czech|tschechien|hungary|ungarn)\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_external_origin(name, zucht=None, breeder=None):
|
||||||
|
"""Externally-acquired founder with genuinely unknown ancestry (pet shop,
|
||||||
|
private hobbyist, foreign cattery). Mirrors extract.is_external_origin so the
|
||||||
|
merge stage never attaches chart/Wurfchronik parents to such animals."""
|
||||||
|
blob = " ".join(p for p in (name, zucht, breeder) if p)
|
||||||
|
if _EXTERNAL_MARKERS_RE.search(blob):
|
||||||
|
return True
|
||||||
|
if _FOREIGN_FROM_RE.search(name or ""):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def canon_name_key(name):
|
||||||
|
"""Connector-folding name key: collapses the cattery connectors so that
|
||||||
|
abbreviation variants of the SAME animal match — e.g. „BlackFire v.d.
|
||||||
|
Kleinen Chaoten“ and „BlackFire von den Kleinen Chaoten“ both fold to the
|
||||||
|
same key (ticket #30). 'v.d.' / 'v. d.' → 'von den', then alnum-reduced.
|
||||||
|
Used as a secondary index next to normalize_name (never for GUIDs)."""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
n = name.lower()
|
||||||
|
n = re.sub(r"\bv\.?\s*d\.?\b", "von den", n) # v.d. / v. d. / vd → von den
|
||||||
|
return "".join(c for c in n if c.isalnum())
|
||||||
|
|
||||||
def get_normalized_contact_name(name):
|
def get_normalized_contact_name(name):
|
||||||
if not name:
|
if not name:
|
||||||
return "", False
|
return "", False
|
||||||
@@ -1518,12 +1552,26 @@ def main():
|
|||||||
norm = z.lower()
|
norm = z.lower()
|
||||||
return "klein" in norm and "chaot" in norm and "extern" not in norm
|
return "klein" in norm and "chaot" in norm and "extern" not in norm
|
||||||
|
|
||||||
|
def is_external_cattery(a):
|
||||||
|
"""True if an animal belongs to a NAMED, non-clan cattery (e.g. 'Black
|
||||||
|
Forest', 'LennyLengo', a foreign line) or the externally-acquired
|
||||||
|
founder markers. Such animals were never in the breeder's own stock, so
|
||||||
|
residency must NOT be propagated onto them (tickets #17/#20 — Hagrid of
|
||||||
|
Black Forest). Animals with no cattery at all are name-only lineage
|
||||||
|
ancestors and stay eligible for propagation."""
|
||||||
|
for z in (a.get("zucht"), a.get("zuchtCanon"), a.get("breeder")):
|
||||||
|
z = (z or "").strip()
|
||||||
|
if z and not is_clan_zucht(z):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
stammbaum_resident_ids = set()
|
stammbaum_resident_ids = set()
|
||||||
for a in stammbaum_only_animals:
|
for a in stammbaum_only_animals:
|
||||||
if is_clan_zucht(a.get("zucht")) or is_clan_zucht(a.get("zuchtCanon")):
|
if is_clan_zucht(a.get("zucht")) or is_clan_zucht(a.get("zuchtCanon")):
|
||||||
stammbaum_resident_ids.add(a["id"])
|
stammbaum_resident_ids.add(a["id"])
|
||||||
|
|
||||||
# Propagate residency to parents of resident offspring
|
# Propagate residency to parents of resident offspring — but never onto
|
||||||
|
# animals from a named external cattery (they were never in this stock).
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
for a in stammbaum_only_animals:
|
for a in stammbaum_only_animals:
|
||||||
if a["id"] in stammbaum_resident_ids:
|
if a["id"] in stammbaum_resident_ids:
|
||||||
@@ -1533,22 +1581,45 @@ def main():
|
|||||||
if normalize_name(cand["name"]) == p_key[0]:
|
if normalize_name(cand["name"]) == p_key[0]:
|
||||||
cand_dob = parse_date(cand["dob"])
|
cand_dob = parse_date(cand["dob"])
|
||||||
if not p_key[1] or cand_dob == p_key[1]:
|
if not p_key[1] or cand_dob == p_key[1]:
|
||||||
|
if is_external_cattery(cand):
|
||||||
|
continue
|
||||||
stammbaum_resident_ids.add(cand["id"])
|
stammbaum_resident_ids.add(cand["id"])
|
||||||
|
|
||||||
# Pre-index Wurfchronik litters from markdown
|
# Pre-index Wurfchronik litters from markdown
|
||||||
md_litters_idx = {}
|
md_litters_idx = {}
|
||||||
|
# …and a date-only index of Wurfchronik litters that NAME both parents. The
|
||||||
|
# Wurfchronik is authoritative: when a chart-position reconstruction picks the
|
||||||
|
# WRONG parents (so the (father,mother,date) key misses) but exactly ONE
|
||||||
|
# Wurfchronik litter exists for that birthdate, attach the animal to it rather
|
||||||
|
# than fabricating a virtual litter with bad parents (tickets #12 Tony,
|
||||||
|
# #15 Odelia, #31 Jamie — Wurfchronik-Vorrang vor chart-position).
|
||||||
|
md_litters_by_date = {}
|
||||||
|
def _md_parent_names(rl):
|
||||||
|
f = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name")
|
||||||
|
m = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name")
|
||||||
|
if not f and not m:
|
||||||
|
_note = rl.get("Notes") or rl.get("notes") or rl.get("Note") or rl.get("note") or ""
|
||||||
|
_m = re.search(r"Eltern:\s*(.+?)\s*\+\s*(.+?)\s*(?:;|$)", _note)
|
||||||
|
if _m and "/" not in _m.group(1) and "/" not in _m.group(2):
|
||||||
|
f, m = _m.group(1).strip(), _m.group(2).strip()
|
||||||
|
return get_normalized_gerbil_name(f), get_normalized_gerbil_name(m)
|
||||||
for rl in raw_litters:
|
for rl in raw_litters:
|
||||||
f_name = get_normalized_gerbil_name(rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName"))
|
f_name, m_name = _md_parent_names(rl)
|
||||||
m_name = get_normalized_gerbil_name(rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName"))
|
|
||||||
ldate = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth"))
|
ldate = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth"))
|
||||||
if f_name and m_name and ldate:
|
if f_name and m_name and ldate:
|
||||||
key = (normalize_name(f_name), normalize_name(m_name), ldate)
|
key = (normalize_name(f_name), normalize_name(m_name), ldate)
|
||||||
md_litters_idx[key] = rl
|
md_litters_idx[key] = rl
|
||||||
|
md_litters_by_date.setdefault(ldate, []).append(rl)
|
||||||
|
|
||||||
# Gender index for parent-ref selection: normalized name → 'male' | 'female'
|
# Gender index for parent-ref selection: normalized name → 'male' | 'female'
|
||||||
# | 'ambiguous'. Drives the gender-aware ranking in pick_parent_ref so a dated
|
# | 'ambiguous'. Drives the gender-aware ranking in pick_parent_ref so a dated
|
||||||
# but wrong-sex ref (e.g. female „Danielle“) cannot win the father slot over
|
# but wrong-sex ref (e.g. female „Danielle“) cannot win the father slot over
|
||||||
# an undated male/unknown one (e.g. „Hagrid Rubeus“).
|
# an undated male/unknown one (e.g. „Hagrid Rubeus“).
|
||||||
|
# A name's gender is "ambiguous" ONLY when BOTH male and female records exist
|
||||||
|
# for it. A definite gender beats an UNKNOWN (None) duplicate — otherwise a
|
||||||
|
# bare DOB-less ancestor box (gender=None) would poison a name that another
|
||||||
|
# record clearly types (e.g. Dorie of Black Forest, Zadar): they would fall
|
||||||
|
# back to None and lose gender-based role disambiguation (tickets #35, #33).
|
||||||
gender_idx = {}
|
gender_idx = {}
|
||||||
for a in stammbaum_only_animals:
|
for a in stammbaum_only_animals:
|
||||||
g = (a.get("gender") or "").lower().strip()
|
g = (a.get("gender") or "").lower().strip()
|
||||||
@@ -1556,10 +1627,13 @@ def main():
|
|||||||
for key in {normalize_name(a.get("name")), normalize_name(get_call_name(a.get("name") or ""))}:
|
for key in {normalize_name(a.get("name")), normalize_name(get_call_name(a.get("name") or ""))}:
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
if key not in gender_idx:
|
cur = gender_idx.get(key, "__unset__")
|
||||||
gender_idx[key] = g
|
if cur == "__unset__" or cur is None:
|
||||||
elif gender_idx[key] != g:
|
gender_idx[key] = g # first value, or upgrade None → definite
|
||||||
gender_idx[key] = "ambiguous"
|
elif g is None or g == cur:
|
||||||
|
pass # keep existing definite gender
|
||||||
|
else:
|
||||||
|
gender_idx[key] = "ambiguous" # genuine male vs female conflict
|
||||||
|
|
||||||
def gender_of_name(name):
|
def gender_of_name(name):
|
||||||
v = gender_idx.get(normalize_name(name))
|
v = gender_idx.get(normalize_name(name))
|
||||||
@@ -1589,7 +1663,26 @@ def main():
|
|||||||
a["_pick_discards"] = pick_discards
|
a["_pick_discards"] = pick_discards
|
||||||
|
|
||||||
a["_mapped_litter_scoped_id"] = None
|
a["_mapped_litter_scoped_id"] = None
|
||||||
if father_ref and mother_ref:
|
|
||||||
|
# Wurfchronik-Vorrang: if the chart parents don't yield an exact
|
||||||
|
# Wurfchronik match but EXACTLY ONE Wurfchronik litter (with named
|
||||||
|
# parents) exists for this birthdate, that authoritative litter wins over
|
||||||
|
# a fabricated chart-position litter (#12 Tony, #15 Odelia, #31 Jamie).
|
||||||
|
dob_val_attach = parse_date(a.get("dob"))
|
||||||
|
if dob_val_attach and not is_external_origin(a.get("name"), a.get("zucht"), a.get("breeder")):
|
||||||
|
same_date = md_litters_by_date.get(dob_val_attach, [])
|
||||||
|
exact_hit = None
|
||||||
|
if father_ref and mother_ref:
|
||||||
|
exact_key = (normalize_name(get_normalized_gerbil_name(father_ref.get("name"))),
|
||||||
|
normalize_name(get_normalized_gerbil_name(mother_ref.get("name"))),
|
||||||
|
dob_val_attach)
|
||||||
|
exact_hit = md_litters_idx.get(exact_key)
|
||||||
|
if not exact_hit and len(same_date) == 1:
|
||||||
|
a["_mapped_litter_scoped_id"] = same_date[0]["_scoped_id"]
|
||||||
|
|
||||||
|
if a["_mapped_litter_scoped_id"]:
|
||||||
|
pass
|
||||||
|
elif father_ref and mother_ref:
|
||||||
f_name = get_normalized_gerbil_name(father_ref.get("name"))
|
f_name = get_normalized_gerbil_name(father_ref.get("name"))
|
||||||
m_name = get_normalized_gerbil_name(mother_ref.get("name"))
|
m_name = get_normalized_gerbil_name(mother_ref.get("name"))
|
||||||
dob_val = parse_date(a.get("dob"))
|
dob_val = parse_date(a.get("dob"))
|
||||||
@@ -1610,13 +1703,19 @@ def main():
|
|||||||
created_virtual_litters[v_key] = l_scoped_id
|
created_virtual_litters[v_key] = l_scoped_id
|
||||||
a["_mapped_litter_scoped_id"] = l_scoped_id
|
a["_mapped_litter_scoped_id"] = l_scoped_id
|
||||||
|
|
||||||
# Try to link parents to actual parsed stammbaum animals
|
# Try to link parents to actual parsed stammbaum animals.
|
||||||
|
# For human override refs (conflict-decisions, method="decision")
|
||||||
|
# skip this loose call-name preliminary linking and let the
|
||||||
|
# global name resolver pick the exact named animal instead
|
||||||
|
# (e.g. resident „Elena“, not „Elena of KK Chaos“ — #15).
|
||||||
|
f_decision = (father_ref or {}).get("method") == "decision"
|
||||||
|
m_decision = (mother_ref or {}).get("method") == "decision"
|
||||||
f_scoped_id = None
|
f_scoped_id = None
|
||||||
m_scoped_id = None
|
m_scoped_id = None
|
||||||
f_dob = parse_date(father_ref.get("dob"))
|
f_dob = parse_date(father_ref.get("dob"))
|
||||||
m_dob = parse_date(mother_ref.get("dob"))
|
m_dob = parse_date(mother_ref.get("dob"))
|
||||||
|
|
||||||
for p_cand in stammbaum_only_animals:
|
for p_cand in ([] if f_decision else stammbaum_only_animals):
|
||||||
p_gender = str(p_cand.get("gender") or "").lower().strip()
|
p_gender = str(p_cand.get("gender") or "").lower().strip()
|
||||||
if p_gender in ["w", "f", "female", "weiblich"]:
|
if p_gender in ["w", "f", "female", "weiblich"]:
|
||||||
continue
|
continue
|
||||||
@@ -1629,7 +1728,7 @@ def main():
|
|||||||
if not f_dob or p_dob == f_dob:
|
if not f_dob or p_dob == f_dob:
|
||||||
f_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}")
|
f_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}")
|
||||||
break
|
break
|
||||||
for p_cand in stammbaum_only_animals:
|
for p_cand in ([] if m_decision else stammbaum_only_animals):
|
||||||
p_gender = str(p_cand.get("gender") or "").lower().strip()
|
p_gender = str(p_cand.get("gender") or "").lower().strip()
|
||||||
if p_gender in ["m", "male", "männlich"]:
|
if p_gender in ["m", "male", "männlich"]:
|
||||||
continue
|
continue
|
||||||
@@ -1766,6 +1865,22 @@ def main():
|
|||||||
father_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name")
|
father_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name")
|
||||||
mother_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name")
|
mother_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name")
|
||||||
|
|
||||||
|
# Parse parents from a free-text Wurf note „Eltern: X + Y“ when the
|
||||||
|
# structured parent names/ids are missing (ticket #9 Beatrice/Q-Wurf,
|
||||||
|
# #11 Silver — ~46 Wurfchronik litters carry parents only in the note).
|
||||||
|
# The order is father + mother (German chart convention); the name
|
||||||
|
# resolver corrects the role afterwards by gender, so a swap is safe.
|
||||||
|
if not father_name and not mother_name:
|
||||||
|
_note = rl.get("Notes") or rl.get("notes") or rl.get("Note") or rl.get("note") or ""
|
||||||
|
_m = re.search(r"Eltern:\s*(.+?)\s*\+\s*(.+?)\s*(?:;|$)", _note)
|
||||||
|
if _m:
|
||||||
|
_p1 = _m.group(1).strip()
|
||||||
|
_p2 = _m.group(2).strip()
|
||||||
|
# Skip ambiguous "Lee/Dean" style alternatives (a slash = unsure).
|
||||||
|
if _p1 and _p2 and "/" not in _p1 and "/" not in _p2:
|
||||||
|
father_name = father_name or _p1
|
||||||
|
mother_name = mother_name or _p2
|
||||||
|
|
||||||
raw_ext_ref = rl.get("ExternalRef") or rl.get("externalRef") or rl.get("Id") or rl.get("id")
|
raw_ext_ref = rl.get("ExternalRef") or rl.get("externalRef") or rl.get("Id") or rl.get("id")
|
||||||
ext_ref_scoped = f"{filename}-{raw_ext_ref}" if raw_ext_ref else None
|
ext_ref_scoped = f"{filename}-{raw_ext_ref}" if raw_ext_ref else None
|
||||||
|
|
||||||
@@ -2257,7 +2372,22 @@ def main():
|
|||||||
if pid:
|
if pid:
|
||||||
parent_litter_dates.setdefault(pid, []).append(ld)
|
parent_litter_dates.setdefault(pid, []).append(ld)
|
||||||
|
|
||||||
|
def _is_empty_shell(g):
|
||||||
|
"""A same-name record carrying no birthdate AND no own parent refs — a
|
||||||
|
DOB-less Stammbaum mention (e.g. Hagrid Rubeus appearing as a bare
|
||||||
|
ancestor box). Such shells must fold into the DOB-/parent-bearing record
|
||||||
|
of the same name even if a stray chart placed them as a parent of an
|
||||||
|
age-incompatible litter (ticket #18). Their own parent-attributions are
|
||||||
|
unreliable, so the parenting-date guard must not keep them separate."""
|
||||||
|
return not g.get("_birth_date") and not (g.get("parentRefs") or [])
|
||||||
|
|
||||||
def are_compatible(g1, g2):
|
def are_compatible(g1, g2):
|
||||||
|
# Same-name empty shell ↔ real record: always merge (see _is_empty_shell).
|
||||||
|
if _is_empty_shell(g1) or _is_empty_shell(g2):
|
||||||
|
if g1["Gender"] == "unknown" or g2["Gender"] == "unknown" \
|
||||||
|
or g1["Gender"] == g2["Gender"]:
|
||||||
|
return True
|
||||||
|
|
||||||
# Must have same gender (or one unknown)
|
# Must have same gender (or one unknown)
|
||||||
if g1["Gender"] != "unknown" and g2["Gender"] != "unknown" and g1["Gender"] != g2["Gender"]:
|
if g1["Gender"] != "unknown" and g2["Gender"] != "unknown" and g1["Gender"] != g2["Gender"]:
|
||||||
bd1 = g1.get("_birth_date")
|
bd1 = g1.get("_birth_date")
|
||||||
@@ -2666,13 +2796,17 @@ def main():
|
|||||||
# Create name lookup for resolved gerbils
|
# Create name lookup for resolved gerbils
|
||||||
gerbil_by_norm_name = {}
|
gerbil_by_norm_name = {}
|
||||||
for g in resolved_gerbils:
|
for g in resolved_gerbils:
|
||||||
|
keys = set()
|
||||||
n_key = normalize_name(g["Name"])
|
n_key = normalize_name(g["Name"])
|
||||||
gerbil_by_norm_name.setdefault(n_key, []).append(g)
|
keys.add(n_key)
|
||||||
|
|
||||||
# Also index by call-name to resolve parents who are only listed by call-name
|
# Also index by call-name to resolve parents who are only listed by call-name
|
||||||
c_key = normalize_name(get_call_name(g["Name"]))
|
keys.add(normalize_name(get_call_name(g["Name"])))
|
||||||
if c_key != n_key:
|
# …and by the connector-folding canon key so abbreviation variants match
|
||||||
gerbil_by_norm_name.setdefault(c_key, []).append(g)
|
# (e.g. „BlackFire v.d. Kleinen Chaoten“ vs „… von den …“ — ticket #30).
|
||||||
|
keys.add(canon_name_key(g["Name"]))
|
||||||
|
for k in keys:
|
||||||
|
if k:
|
||||||
|
gerbil_by_norm_name.setdefault(k, []).append(g)
|
||||||
|
|
||||||
# Map raw Guid if present (convert if old_id mapped to new_guid)
|
# Map raw Guid if present (convert if old_id mapped to new_guid)
|
||||||
for l in resolved_litters:
|
for l in resolved_litters:
|
||||||
@@ -2708,29 +2842,49 @@ def main():
|
|||||||
"""
|
"""
|
||||||
if not name:
|
if not name:
|
||||||
return None
|
return None
|
||||||
cands = []
|
# Try the exact normalized key, then the connector-folding canon key and
|
||||||
for c in gerbil_by_norm_name.get(normalize_name(name), []):
|
# the call-name (handles v.d. ↔ von den abbreviation variants — #30).
|
||||||
final_id = gerbil_id_map.get(c["Id"])
|
lookups = []
|
||||||
if not final_id:
|
for k in (normalize_name(name), canon_name_key(name),
|
||||||
|
normalize_name(get_call_name(name))):
|
||||||
|
if k and k not in lookups:
|
||||||
|
lookups.append(k)
|
||||||
|
# Resolve key-by-key so an EXACT full-name match (lookups[0]) wins over a
|
||||||
|
# mere call-name/canon fallback — e.g. override mother „Elena“ must pick
|
||||||
|
# the resident „Elena“, not „Elena of KK Chaos“ whose call-name is also
|
||||||
|
# „Elena“ (ticket #15).
|
||||||
|
seen_ids = set()
|
||||||
|
for k in lookups:
|
||||||
|
cands = []
|
||||||
|
for c in gerbil_by_norm_name.get(k, []):
|
||||||
|
if c["Id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(c["Id"])
|
||||||
|
final_id = gerbil_id_map.get(c["Id"])
|
||||||
|
if not final_id:
|
||||||
|
continue
|
||||||
|
final_c = gerbil_by_id_final.get(final_id)
|
||||||
|
if not final_c:
|
||||||
|
continue
|
||||||
|
# Parent must be age-plausible: born before the litter and within
|
||||||
|
# the gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter).
|
||||||
|
if not parent_age_plausible(final_c["DateOfBirth"], litter_date):
|
||||||
|
continue
|
||||||
|
cands.append(final_c)
|
||||||
|
if not cands:
|
||||||
continue
|
continue
|
||||||
final_c = gerbil_by_id_final.get(final_id)
|
# Within a key, a candidate whose FULL name equals the lookup beats one
|
||||||
if not final_c:
|
# that only matched via call-name (resident „Elena“ > „Elena of KK
|
||||||
continue
|
# Chaos“ — #15). Stable sort keeps prior ordering otherwise.
|
||||||
# Parent must be age-plausible: born before the litter and within the
|
cands.sort(key=lambda c: 0 if normalize_name(c["Name"]) == k else 1)
|
||||||
# gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter).
|
# Prefer the gender expected for this role, then unknown, then anything.
|
||||||
if not parent_age_plausible(final_c["DateOfBirth"], litter_date):
|
for pool in (
|
||||||
continue
|
[c for c in cands if c["Gender"] == prefer_gender],
|
||||||
cands.append(final_c)
|
[c for c in cands if c["Gender"] == "unknown"],
|
||||||
if not cands:
|
cands,
|
||||||
return None
|
):
|
||||||
# Prefer the gender expected for this role, then unknown, then anything.
|
if pool:
|
||||||
for pool in (
|
return pool[0]
|
||||||
[c for c in cands if c["Gender"] == prefer_gender],
|
|
||||||
[c for c in cands if c["Gender"] == "unknown"],
|
|
||||||
cands,
|
|
||||||
):
|
|
||||||
if pool:
|
|
||||||
return pool[0]
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for l in resolved_litters:
|
for l in resolved_litters:
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
|
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
|
||||||
- Fotos zugeordnet: **435**
|
- Fotos zugeordnet: **435**
|
||||||
- Würfe aus der Wurfchronik: **752**
|
- Würfe aus der Wurfchronik: **752**
|
||||||
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17)
|
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 170, nur über Geburtsdatum: 100; mehrdeutig: 17)
|
||||||
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
||||||
|
|
||||||
## Zusammenführungs-Schlüssel
|
## Zusammenführungs-Schlüssel
|
||||||
|
|||||||
@@ -417,6 +417,54 @@ if os.path.exists(kazuya_path):
|
|||||||
else:
|
else:
|
||||||
print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.")
|
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
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print(f"\n{failed} test(s) FAILED")
|
print(f"\n{failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -154,6 +154,24 @@ check("names_overlap: matching father name", m.names_overlap(litter(None, fname=
|
|||||||
check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B")))
|
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 ──
|
# ── 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 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 AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27"))
|
||||||
@@ -414,6 +432,68 @@ check("contracts: animal-less record has empty Animals list",
|
|||||||
_sale3 and _sale3[0]["Animals"] == [])
|
_sale3 and _sale3[0]["Animals"] == [])
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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")
|
||||||
|
else:
|
||||||
|
print("note: output/resolved_import.json not present — skipped integration assertions")
|
||||||
|
|
||||||
|
|
||||||
if check.failed:
|
if check.failed:
|
||||||
print(f"\n{check.failed} test(s) FAILED")
|
print(f"\n{check.failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user