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>
382 lines
14 KiB
TypeScript
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])
|
|
})
|
|
})
|