Files
GerbilManager/gerbil-manager-web/src/pedigree/__tests__/build.test.ts
Gulum 3715e32303 fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
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>
2026-06-22 14:02:45 +02:00

382 lines
14 KiB
TypeScript

/**
* FEAT-4: Tests für den Ahnenbaum-Builder (build.ts).
*
* Reine Daten-Tests (keine DOM/React): In-Memory-PedigreeSource mit
* Aufruf-Zählern statt API.
*/
import { describe, expect, it } from 'vitest'
import type { Gerbil, Litter } from '../../api/types'
import {
ancestorsAt,
buildPedigree,
collectNodes,
expandPedigree,
toRawNodeDatum,
} from '../build'
import type { AnimalNode, PedigreeNode, PedigreeSource } from '../types'
/* ── Testdaten-Helfer ─────────────────────────────────────────── */
function makeGerbil(id: string, name: string, litterId: string | null = null): Gerbil {
return {
id,
name,
gender: 'unknown',
status: 'Breeding',
dateOfBirth: null,
dateOfDeath: null,
causeOfDeath: null,
goHomeDate: null,
litterId,
enclosureId: null,
colorVarietyId: null,
originContactId: null,
receiverContactId: null,
genotype: null,
notes: null,
}
}
function makeLitter(id: string, fatherId: string | null, motherId: string | null): Litter {
return {
id,
name: `Wurf ${id}`,
date: '2025-01-01',
totalBorn: null,
expectedGoHomeDate: null,
notes: null,
fatherId,
motherId,
}
}
interface CountingSource extends PedigreeSource {
gerbilCalls: Map<string, number>
litterCalls: Map<string, number>
}
function makeSource(gerbils: Gerbil[], litters: Litter[]): CountingSource {
const gerbilMap = new Map(gerbils.map((g) => [g.id, g]))
const litterMap = new Map(litters.map((l) => [l.id, l]))
const gerbilCalls = new Map<string, number>()
const litterCalls = new Map<string, number>()
return {
gerbilCalls,
litterCalls,
getGerbil: (id) => {
gerbilCalls.set(id, (gerbilCalls.get(id) ?? 0) + 1)
return Promise.resolve(gerbilMap.get(id) ?? null)
},
getLitter: (id) => {
litterCalls.set(id, (litterCalls.get(id) ?? 0) + 1)
return Promise.resolve(litterMap.get(id) ?? null)
},
}
}
/**
* Standard-Familie:
* kind ← (vater, mutter); vater ← (opa, oma); mutter ohne Wurf.
* opa hat einen Wurf (uropas), der NICHT vorab geladen werden soll,
* wenn die Tiefe es begrenzt.
*/
function familySource(): CountingSource {
return makeSource(
[
makeGerbil('kind', 'Kind', 'wurf-kind'),
makeGerbil('vater', 'Vater', 'wurf-vater'),
makeGerbil('mutter', 'Mutter', null),
makeGerbil('opa', 'Opa', 'wurf-opa'),
makeGerbil('oma', 'Oma', null),
makeGerbil('uropa', 'Uropa', null),
],
[
makeLitter('wurf-kind', 'vater', 'mutter'),
makeLitter('wurf-vater', 'opa', 'oma'),
makeLitter('wurf-opa', 'uropa', null),
],
)
}
function animal(node: PedigreeNode | undefined): AnimalNode {
if (!node || node.kind !== 'animal') throw new Error(`Tier-Knoten erwartet, war: ${node?.kind}`)
return node
}
/* ── buildPedigree ────────────────────────────────────────────── */
describe('buildPedigree', () => {
it('baut den Baum mit Pfaden, Vater-vor-Mutter und Platzhaltern für Gründertiere', async () => {
const root = await buildPedigree(familySource(), 'kind')
expect(root).not.toBeNull()
expect(root!.gerbil.name).toBe('Kind')
expect(root!.path).toBe('')
const vater = animal(root!.father)
const mutter = animal(root!.mother)
expect(vater.gerbil.name).toBe('Vater')
expect(vater.path).toBe('v')
expect(mutter.gerbil.name).toBe('Mutter')
expect(mutter.path).toBe('m')
// Mutter ohne Wurf: beide Eltern als „unbekannt“-Platzhalter (Symmetrie).
expect(mutter.father).toEqual({ kind: 'unknown', path: 'mv' })
expect(mutter.mother).toEqual({ kind: 'unknown', path: 'mm' })
expect(mutter.expandable).toBe(false)
})
it('liefert null, wenn das Wurzeltier nicht existiert', async () => {
expect(await buildPedigree(familySource(), 'gibt-es-nicht')).toBeNull()
})
it('begrenzt die Tiefe und markiert die Ladegrenze als erweiterbar', async () => {
const root = await buildPedigree(familySource(), 'kind', 1)
const vater = animal(root!.father)
const mutter = animal(root!.mother)
// Generation 1 ist die Grenze: keine Eltern geladen.
expect(vater.father).toBeUndefined()
expect(vater.mother).toBeUndefined()
// Vater hat einen Wurf → nachladbar; Mutter ohne Wurf → nicht.
expect(vater.expandable).toBe(true)
expect(mutter.expandable).toBe(false)
})
it('Standardtiefe 4: lädt genau 4 Generationen', async () => {
const root = await buildPedigree(familySource(), 'kind')
// Uropa liegt in Generation 3 (kind → vater → opa → uropa) und hat keinen
// Wurf → seine Eltern sind als Platzhalter geladen (Generation 4 = Grenze).
const uropa = animal(animal(animal(root!.father).father).father)
expect(uropa.gerbil.name).toBe('Uropa')
expect(uropa.father).toEqual({ kind: 'unknown', path: 'vvvv' })
})
it('setzt Platzhalter für fehlenden Eltern-Eintrag im Wurf und für 404-Tiere', async () => {
const source = makeSource(
[makeGerbil('a', 'A', 'wurf-a')],
// Vater-Id zeigt ins Leere (gelöschtes Tier), Mutter fehlt im Wurf.
[makeLitter('wurf-a', 'geloescht', null)],
)
const root = await buildPedigree(source, 'a')
expect(root!.father).toEqual({ kind: 'unknown', path: 'v' })
expect(root!.mother).toEqual({ kind: 'unknown', path: 'm' })
})
it('behandelt einen 404-Wurf wie unbekannte Eltern', async () => {
const source = makeSource([makeGerbil('a', 'A', 'wurf-weg')], [])
const root = await buildPedigree(source, 'a')
expect(root!.father).toEqual({ kind: 'unknown', path: 'v' })
expect(root!.mother).toEqual({ kind: 'unknown', path: 'm' })
})
it('beendet Zyklen (Tier als eigener Vorfahre) ohne Endlosschleife', async () => {
const source = makeSource(
[makeGerbil('a', 'A', 'wurf-a'), makeGerbil('b', 'B', 'wurf-b')],
[makeLitter('wurf-a', 'b', null), makeLitter('wurf-b', 'a', null)],
)
const root = await buildPedigree(source, 'a')
const b = animal(root!.father)
const aAgain = animal(b.father)
expect(aAgain.gerbil.id).toBe('a')
// Zyklus: keine Eltern, nicht erweiterbar (trotz vorhandenem Wurf).
expect(aAgain.father).toBeUndefined()
expect(aAgain.expandable).toBe(false)
})
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const vater = animal(root!.father)
const mutter = animal(root!.mother)
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
expect(vater.siblingPairing).toBeUndefined()
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
expect(animal(mutter.father).gerbil.name).toBe('Opa')
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
})
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w1'),
makeGerbil('vater', 'Vater', 'w2'),
makeGerbil('mutter', 'Mutter', 'w3'),
makeGerbil('opa', 'Opa', null),
],
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w1'),
makeGerbil('vater', 'Vater', 'w2'),
makeGerbil('mutter', 'Mutter', 'w3'),
makeGerbil('opa', 'Opa', null),
],
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
)
const root = await buildPedigree(source, 'kind')
expect(animal(animal(root!.father).father).gerbil.id).toBe('opa')
expect(animal(animal(root!.mother).father).gerbil.id).toBe('opa')
})
})
/* ── expandPedigree ───────────────────────────────────────────── */
describe('expandPedigree', () => {
it('lädt an der Grenze eine weitere Generation nach und teilt unveränderte Äste', async () => {
const source = familySource()
const root = await buildPedigree(source, 'kind', 1)
const vorher = animal(root!.father)
expect(vorher.father).toBeUndefined()
const next = await expandPedigree(source, root!, 'v')
// Neuer Baum: erweiterter Ast hat jetzt Eltern …
const vater = animal(next.father)
expect(animal(vater.father).gerbil.name).toBe('Opa')
expect(animal(vater.mother).gerbil.name).toBe('Oma')
// … Wurzel ist ein neues Objekt, der unberührte Mutter-Ast wird geteilt.
expect(next).not.toBe(root)
expect(next.mother).toBe(root!.mother)
})
it('erweiterte Knoten an der neuen Grenze sind wiederum erweiterbar', async () => {
const source = familySource()
const root = await buildPedigree(source, 'kind', 1)
const next = await expandPedigree(source, root!, 'v')
// Opa (Generation 2, neue Grenze) hat einen Wurf → erweiterbar.
expect(animal(animal(next.father).father).expandable).toBe(true)
})
})
/* ── Caching-Vertrag der Quelle ───────────────────────────────── */
describe('Quellen-Aufrufe', () => {
it('fragt jedes Tier und jeden Wurf höchstens einmal pro Aufbau an', async () => {
const source = familySource()
await buildPedigree(source, 'kind')
for (const [id, count] of source.gerbilCalls) {
expect(count, `Tier ${id} mehrfach geladen`).toBe(1)
}
for (const [id, count] of source.litterCalls) {
expect(count, `Wurf ${id} mehrfach geladen`).toBe(1)
}
})
})
/* ── toRawNodeDatum / collectNodes ────────────────────────────── */
describe('toRawNodeDatum', () => {
it('konvertiert in die react-d3-tree-Struktur (Name, Pfad, Kinder [Vater, Mutter])', async () => {
const root = await buildPedigree(familySource(), 'kind', 1)
const datum = toRawNodeDatum(root!, 'unbekannt')
expect(datum.name).toBe('Kind')
expect(datum.attributes).toMatchObject({ path: '', kind: 'animal', expandable: false })
expect(datum.children).toHaveLength(2)
expect(datum.children![0].name).toBe('Vater')
expect(datum.children![0].attributes).toMatchObject({ path: 'v', expandable: true })
expect(datum.children![1].name).toBe('Mutter')
// Grenz-Knoten: keine children-Eigenschaft (nicht geladen ≠ leer).
expect(datum.children![0].children).toBeUndefined()
})
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const datum = toRawNodeDatum(root!, 'unbekannt')
// Vater behält seinen vollen Vorfahren-Ast …
const vater = datum.children![0]
expect(vater.name).toBe('Vater')
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
const mutter = datum.children![1]
expect(mutter.name).toBe('Mutter')
expect(mutter.children).toHaveLength(1)
expect(mutter.children![0].attributes).toMatchObject({
kind: 'sibling-ref',
refName: 'Vater',
refPath: 'v',
})
})
it('rendert Platzhalter mit dem übergebenen Label', async () => {
const root = await buildPedigree(familySource(), 'mutter')
const datum = toRawNodeDatum(root!, 'unbekannt')
expect(datum.children![0]).toEqual({
name: 'unbekannt',
attributes: { path: 'v', kind: 'unknown' },
})
})
it('collectNodes indiziert jeden Knoten unter seinem Pfad', async () => {
const root = await buildPedigree(familySource(), 'kind')
const nodes = collectNodes(root!)
expect(nodes.get('')).toBe(root)
expect(animal(nodes.get('vv')).gerbil.name).toBe('Opa')
expect(nodes.get('mv')?.kind).toBe('unknown')
})
})
/* ── ancestorsAt (Druckansicht) ───────────────────────────────── */
describe('ancestorsAt', () => {
it('liefert die Plätze einer Generation in Ahnentafel-Ordnung mit Lücken', async () => {
const root = await buildPedigree(familySource(), 'kind')
expect(ancestorsAt(root!, 0)).toEqual([root])
const gen1 = ancestorsAt(root!, 1)
expect(gen1.map((n) => (n?.kind === 'animal' ? n.gerbil.name : n?.kind))).toEqual([
'Vater',
'Mutter',
])
const gen2 = ancestorsAt(root!, 2)
expect(gen2).toHaveLength(4)
expect(animal(gen2[0]!).gerbil.name).toBe('Opa')
expect(animal(gen2[1]!).gerbil.name).toBe('Oma')
expect(gen2[2]?.kind).toBe('unknown') // Mutter-Seite: Platzhalter
expect(gen2[3]?.kind).toBe('unknown')
const gen3 = ancestorsAt(root!, 3)
expect(gen3).toHaveLength(8)
expect(animal(gen3[0]!).gerbil.name).toBe('Uropa')
expect(gen3[1]?.kind).toBe('unknown') // Opas Wurf ohne Mutter
// Hinter Oma (ohne Wurf, Platzhalter-Eltern) …
expect(gen3[2]?.kind).toBe('unknown')
// … hinter Platzhaltern: leere Plätze.
expect(gen3.slice(4)).toEqual([null, null, null, null])
})
})