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 <Vater>, 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 <noreply@anthropic.com>
201 lines
9.3 KiB
TypeScript
201 lines
9.3 KiB
TypeScript
/** QA-1: Stammbaum (FEAT-4) — interaktiver Viewer, Re-Root, Druckansicht. */
|
||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||
|
||
const t = de.pages.stammbaum
|
||
|
||
test('Viewer rendert die Ahnen-Karten und re-rootet per Tipp aufs Elterntier', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
await expect(page.getByRole('heading', { name: t.titleFor('Krümel') })).toBeVisible()
|
||
|
||
// Erst die Ansicht einpassen, damit alle Karten im SVG-Viewport liegen
|
||
// (sonst tippt der Klick auf dem Laptop ins Leere).
|
||
const fit = page.getByRole('button', { name: t.zoomFit })
|
||
if (await fit.isVisible()) await fit.click()
|
||
|
||
// Tipp auf den Vater -> Stammbaum wurzelt neu auf Fridolin.
|
||
// (force: die d3-Baum-Animation bewegt die Karten noch eine Weile —
|
||
// Playwrights Stabilitäts-Check würde sonst dauerhaft warten.)
|
||
await page.waitForTimeout(900)
|
||
await page
|
||
.locator('.pedigree-card:not(.pedigree-card--root):not(.pedigree-card--unknown)')
|
||
.filter({ hasText: 'Fridolin' })
|
||
.first()
|
||
.click({ force: true })
|
||
await page.waitForURL('**/rennmaeuse/fridolin/stammbaum')
|
||
await expect(page.getByRole('heading', { name: t.titleFor('Fridolin') })).toBeVisible()
|
||
})
|
||
|
||
test('Inzuchtkoeffizient wird angezeigt', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
const inzucht = page.locator('.stammbaum-inzucht')
|
||
await expect(inzucht).toBeVisible()
|
||
await expect(inzucht).toContainText(t.inbreeding.label)
|
||
// 0.0625 -> de-DE mit max. 1 Nachkommastelle -> "6,3 %"
|
||
await expect(inzucht).toContainText('6,3')
|
||
})
|
||
|
||
test('Druckansicht (Ahnentafel) erscheint im Print-Medium', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
|
||
await page.emulateMedia({ media: 'print' })
|
||
// Spaltenüberschriften der Ahnentafel + Erstellt-am-Zeile
|
||
await expect(page.getByText(t.printView.generations[1], { exact: true })).toBeVisible()
|
||
await expect(page.getByText(new RegExp(t.printView.createdOn))).toBeVisible()
|
||
await page.emulateMedia({ media: 'screen' })
|
||
})
|
||
|
||
test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ page }) => {
|
||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||
})
|
||
|
||
test('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
|
||
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
|
||
const nameLink = page.getByRole('link', { name: 'Fridolin' })
|
||
await expect(nameLink).toBeVisible()
|
||
// href korrekt gesetzt
|
||
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
|
||
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
|
||
await nameLink.focus()
|
||
await page.keyboard.press('Enter')
|
||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
|
||
})
|
||
|
||
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||
// Titel zeigt '(ohne Namen)'
|
||
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||
})
|
||
|
||
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
|
||
const hints = page.locator('.stammbaum-hints')
|
||
await expect(hints).toBeVisible()
|
||
await expect(hints).toContainText(t.tapHint)
|
||
await expect(hints).toContainText(t.hintName)
|
||
await expect(hints).toContainText(t.hintExpand)
|
||
})
|
||
|
||
test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
|
||
// Einpassen, damit die 4. Generation (Emil mit +) im Viewport liegt.
|
||
const fit = page.getByRole('button', { name: t.zoomFit })
|
||
if (await fit.isVisible()) await fit.click()
|
||
await page.waitForTimeout(600)
|
||
|
||
// +-Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
|
||
const expandBtn = page.getByRole('button', { name: t.expand }).first()
|
||
await expect(expandBtn).toBeVisible()
|
||
|
||
// Klick auf +: Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
|
||
await expandBtn.click({ force: true })
|
||
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.
|
||
await page.goto('/rennmaeuse/fridolin/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
|
||
const panel = page.locator('.stammbaum-litters-panel')
|
||
await expect(panel).toBeVisible()
|
||
await expect(panel).toContainText(t.littersTitle)
|
||
await expect(panel).toContainText('Wurf K')
|
||
await expect(panel).toContainText('5')
|
||
|
||
// Link-Klick → Wurf-Detailseite
|
||
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
|
||
await expect(wurfLink).toBeVisible()
|
||
await wurfLink.click()
|
||
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
|
||
})
|
||
|
||
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
|
||
})
|
||
|
||
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
|
||
})
|
||
|
||
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
|
||
})
|
||
|
||
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
|
||
skipUnlessMock()
|
||
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
|
||
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
|
||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||
|
||
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
|
||
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
|
||
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
|
||
|
||
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
|
||
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
|
||
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
|
||
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
|
||
})
|