From 3715e32303c5af87c9534e975ef2a858f74eaad0 Mon Sep 17 00:00:00 2001 From: Gulum Date: Mon, 22 Jun 2026 14:02:45 +0200 Subject: [PATCH] =?UTF-8?q?fix(stammbaum):=20Geschwister-Verpaarung=20erke?= =?UTF-8?q?nnen=20und=20im=20Diagramm=20zusammenf=C3=BChren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt. Import (merge_and_resolve.py): - Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen; bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen unverwandter datumloser Stubs). Falsch-Merges 112 -> 70. - Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem auflösen. Aufgelöste Eltern 200/206 -> 240/260. - Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu, entfernt Selbst-Verpaarungen und unmögliche Doppelrollen. Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0. - Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene extrahiert und in test_merge_resolve.py (26 Tests) abgesichert. Frontend (pedigree): - buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem Verweis-Knoten zusammen ("Geschwister von , Eltern siehe oben"); die Vaterlinie zeigt die gemeinsamen Großeltern einmal. - Druck-Ahnentafel bleibt vollständig (ignoriert die Marke). - Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt. Co-Authored-By: Claude Opus 4.8 --- gerbil-manager-web/e2e/mock-data.ts | 10 + gerbil-manager-web/e2e/stammbaum.spec.ts | 27 ++ .../src/pages/StammbaumPage.tsx | 32 +- gerbil-manager-web/src/pages/stammbaum.css | 38 +++ .../src/pedigree/__tests__/build.test.ts | 70 ++++ gerbil-manager-web/src/pedigree/build.ts | 37 +++ gerbil-manager-web/src/pedigree/types.ts | 9 + gerbil-manager-web/src/strings/de.ts | 6 + tools/import/merge_and_resolve.py | 314 +++++++++++++++--- tools/import/output/review-report.md | 38 +-- tools/import/test_merge_resolve.py | 160 +++++++++ 11 files changed, 666 insertions(+), 75 deletions(-) create mode 100644 tools/import/test_merge_resolve.py diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index d1fe143..73845cf 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -171,9 +171,19 @@ export function seedDb(): MockDb { }, // UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail. gerbil('nameless-stub', '', 'male', '2023-01-01', null, null), + // SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide + // Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das + // Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen. + gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'), + gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'), + { ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false }, + { ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false }, + gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'), ] const litters: Litter[] = [ + { id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' }, + { id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' }, { id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 }, { id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' }, { id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' }, diff --git a/gerbil-manager-web/e2e/stammbaum.spec.ts b/gerbil-manager-web/e2e/stammbaum.spec.ts index 11e0ad7..982ef67 100644 --- a/gerbil-manager-web/e2e/stammbaum.spec.ts +++ b/gerbil-manager-web/e2e/stammbaum.spec.ts @@ -111,6 +111,33 @@ test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND) await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 }) }) +test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => { + skipUnlessMock() + // 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus + // demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister. + await page.goto('/rennmaeuse/inzucht-kind/stammbaum') + await expect(page.locator('.pedigree-card').first()).toBeVisible() + + const fit = page.getByRole('button', { name: t.zoomFit }) + if (await fit.isVisible()) await fit.click() + await page.waitForTimeout(600) + + // Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den + // Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig). + await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible() + await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible() + await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible() + + // Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater). + const ref = page.locator('.pedigree-card--sibling') + await expect(ref).toBeVisible() + await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock')) + await expect(ref).toContainText(t.siblingPairing.refHint) + + // Mini-Legende ergänzt den Geschwister-Hinweis. + await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings) +}) + test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => { skipUnlessMock() // Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen. diff --git a/gerbil-manager-web/src/pages/StammbaumPage.tsx b/gerbil-manager-web/src/pages/StammbaumPage.tsx index 21f0547..d52b6d3 100644 --- a/gerbil-manager-web/src/pages/StammbaumPage.tsx +++ b/gerbil-manager-web/src/pages/StammbaumPage.tsx @@ -168,6 +168,13 @@ export default function StammbaumPage() { /* ── react-d3-tree-Daten ── */ const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root]) const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t]) + const hasSiblingPairing = useMemo( + () => + nodesByPath + ? [...nodesByPath.values()].some((n) => n.kind === 'animal' && n.siblingPairing != null) + : false, + [nodesByPath], + ) /* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */ const canvasRef = useRef(null) @@ -246,6 +253,28 @@ export default function StammbaumPage() { const renderNode = useCallback( ({ nodeDatum }: CustomNodeElementProps) => { const path = String(nodeDatum.attributes?.path ?? '') + // Verweis-Knoten der Geschwister-Verpaarung: rein aus den Attributen + // gerendert (kein Eintrag in nodesByPath). + if (nodeDatum.attributes?.kind === 'sibling-ref') { + const refName = String(nodeDatum.attributes.refName ?? '') || de.pages.gerbils.nameless + return ( + + +
+ +
+ + {t.siblingPairing.refLabel(refName)} + + {t.siblingPairing.refHint} +
+
+
+
+ ) + } const node = nodesByPath?.get(path) return ( @@ -353,6 +382,7 @@ export default function StammbaumPage() {
  • {t.tapHint}
  • {t.hintName}
  • {t.hintExpand}
  • + {hasSiblingPairing &&
  • {t.hintSiblings}
  • } @@ -429,7 +459,7 @@ function PedigreeCard({ )} {dob && * {dob}} - {node.expandable && ( + {node.expandable && !node.siblingPairing && (