FEAT-4: pedigree data module - depth-limited ancestor traversal, lazy expand, cycle guard, rd3t conversion (15 tests)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
302
gerbil-manager-web/src/pedigree/__tests__/build.test.ts
Normal file
302
gerbil-manager-web/src/pedigree/__tests__/build.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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: 'Active',
|
||||
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, 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('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('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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user