/** * 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 litterCalls: Map } 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() const litterCalls = new Map() 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]) }) })