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:
2026-06-06 00:17:49 +02:00
parent 01923d1b87
commit 61cfab046a
7 changed files with 654 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/** Typed API functions for the Würfe (litters) resource. */
import { api, resources } from './client'
import type { Litter } from './types'
export function getLitter(id: string): Promise<Litter> {
return api.get<Litter>(`${resources.litters}/${id}`)
}

View File

@@ -0,0 +1,22 @@
/**
* FEAT-4: Stammbaum-bezogene API-Aufrufe.
*
* Der Inzuchtkoeffizient wird serverseitig über die VOLLE Ahnentiefe berechnet
* (Board-Entscheidung; UI zeigt 4 Generationen, Koeffizient zählt alle).
* Endpunkt entsteht in DATA-2-Folgearbeit — bis dahin antwortet er mit 404 und
* die UI zeigt einen „wird berechnet …“-Zustand.
*/
import { api, resources } from './client'
export interface InbreedingCoefficient {
/** Koeffizient nach Wright, 0..1. */
coefficient: number
}
export async function getInbreedingCoefficient(gerbilId: string): Promise<number> {
const raw = await api.get<InbreedingCoefficient | number>(
`${resources.gerbils}/${gerbilId}/inbreeding-coefficient`,
)
// Defensiv: Endpunkt-Form ist noch nicht final (nacktes number vs. Objekt).
return typeof raw === 'number' ? raw : raw.coefficient
}

View 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])
})
})

View File

@@ -0,0 +1,182 @@
/**
* FEAT-4: Aufbau und Erweiterung des Ahnenbaums + Konvertierung nach
* react-d3-tree.
*
* Clientseitige Traversierung über die vorhandenen Endpunkte:
* Tier → sein Wurf (litterId) → Vater/Mutter → rekursiv, tiefenbegrenzt
* (Standard 4 Generationen, Board-Entscheidung). Tiefere Generationen werden
* auf Wunsch nachgeladen (expandPedigree).
*
* Zyklenschutz: Steht ein Tier bereits auf dem Pfad zur Wurzel (defekte Daten:
* „eigener Vorfahre“), wird die Rekursion dort beendet und der Knoten nicht
* erweiterbar markiert.
*/
import type { RawNodeDatum } from 'react-d3-tree'
import type { Gerbil } from '../api/types'
import type { AnimalNode, PedigreeNode, PedigreePath, PedigreeSource, UnknownNode } from './types'
/** Standard-Generationstiefe der Erstanzeige (Board: UI 4 Generationen). */
export const DEFAULT_GENERATIONS = 4
function unknown(path: PedigreePath): UnknownNode {
return { kind: 'unknown', path }
}
async function parentNode(
source: PedigreeSource,
id: string | null | undefined,
path: PedigreePath,
generationsLeft: number,
ancestors: ReadonlySet<string>,
): Promise<PedigreeNode> {
if (!id) return unknown(path)
const gerbil = await source.getGerbil(id)
if (!gerbil) return unknown(path)
return buildAnimal(source, gerbil, path, generationsLeft, ancestors)
}
async function buildAnimal(
source: PedigreeSource,
gerbil: Gerbil,
path: PedigreePath,
generationsLeft: number,
ancestors: ReadonlySet<string>,
): Promise<AnimalNode> {
const isCycle = ancestors.has(gerbil.id)
if (isCycle || generationsLeft <= 0) {
// Ladegrenze (oder Zyklus): Eltern nicht laden. Erweiterbar nur, wenn es
// einen Wurf gibt und kein Zyklus vorliegt.
return { kind: 'animal', path, gerbil, expandable: !isCycle && gerbil.litterId != null }
}
if (!gerbil.litterId) {
// Gründertier ohne erfassten Wurf: Eltern unbekannt. Platzhalter wahren
// die Symmetrie der Ahnentafel.
return {
kind: 'animal',
path,
gerbil,
father: unknown(path + 'v'),
mother: unknown(path + 'm'),
expandable: false,
}
}
// Wurf laden; bei 404 (gelöschter Wurf) zählen die Eltern als unbekannt.
const litter = await source.getLitter(gerbil.litterId)
const nextAncestors = new Set(ancestors)
nextAncestors.add(gerbil.id)
const [father, mother] = await Promise.all([
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
])
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
}
/**
* Baut den Ahnenbaum für ein Wurzeltier. `null`, wenn das Tier selbst nicht
* existiert (404).
*/
export async function buildPedigree(
source: PedigreeSource,
rootId: string,
generations: number = DEFAULT_GENERATIONS,
): Promise<AnimalNode | null> {
const root = await source.getGerbil(rootId)
if (!root) return null
return buildAnimal(source, root, '', generations, new Set())
}
/**
* Lädt für den Knoten an `path` weitere `generations` Vorfahren-Generationen
* nach und liefert eine NEUE Wurzel (unveränderte Äste werden strukturell
* geteilt — geeignet für React-State).
*/
export async function expandPedigree(
source: PedigreeSource,
root: AnimalNode,
path: PedigreePath,
generations = 1,
): Promise<AnimalNode> {
const next = await expandAt(source, root, path, new Set(), generations)
return next as AnimalNode
}
async function expandAt(
source: PedigreeSource,
node: PedigreeNode,
target: PedigreePath,
ancestors: ReadonlySet<string>,
generations: number,
): Promise<PedigreeNode> {
if (node.kind === 'unknown') return node
if (node.path === target) {
return buildAnimal(source, node.gerbil, node.path, generations, ancestors)
}
if (!target.startsWith(node.path)) return node
const branch = target.charAt(node.path.length) // 'v' | 'm'
const nextAncestors = new Set(ancestors)
nextAncestors.add(node.gerbil.id)
if (branch === 'v' && node.father) {
return { ...node, father: await expandAt(source, node.father, target, nextAncestors, generations) }
}
if (branch === 'm' && node.mother) {
return { ...node, mother: await expandAt(source, node.mother, target, nextAncestors, generations) }
}
return node
}
/* ── Konvertierung für react-d3-tree ──────────────────────────── */
/**
* Konvertiert den Ahnenbaum in die react-d3-tree-Struktur. Kinder eines
* Knotens sind [Vater, Mutter] (Vater oben — klassische Ahnentafel-Ordnung).
* Die Karten-Renderfunktion findet den vollen Knoten über `attributes.path`
* (siehe collectNodes); RawNodeDatum.attributes erlaubt nur flache Primitive.
*/
export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNodeDatum {
if (node.kind === 'unknown') {
return { name: unknownLabel, attributes: { path: node.path, kind: 'unknown' } }
}
const datum: RawNodeDatum = {
name: node.gerbil.name,
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
}
if (node.father && node.mother) {
datum.children = [
toRawNodeDatum(node.father, unknownLabel),
toRawNodeDatum(node.mother, unknownLabel),
]
}
return datum
}
/** Index aller Knoten nach Position (für die Karten-Renderfunktion). */
export function collectNodes(root: PedigreeNode): Map<PedigreePath, PedigreeNode> {
const map = new Map<PedigreePath, PedigreeNode>()
const walk = (node: PedigreeNode): void => {
map.set(node.path, node)
if (node.kind === 'animal') {
if (node.father) walk(node.father)
if (node.mother) walk(node.mother)
}
}
walk(root)
return map
}
/**
* Alle Plätze einer Generation in Ahnentafel-Ordnung (Vater-Linie zuerst).
* Länge = 2^generation; `null` = Platz nicht belegt/geladen (Druckansicht
* rendert dafür leere Zellen).
*/
export function ancestorsAt(root: PedigreeNode, generation: number): (PedigreeNode | null)[] {
let slots: (PedigreeNode | null)[] = [root]
for (let g = 0; g < generation; g++) {
slots = slots.flatMap((node) =>
node && node.kind === 'animal' ? [node.father ?? null, node.mother ?? null] : [null, null],
)
}
return slots
}

View File

@@ -0,0 +1,53 @@
/**
* FEAT-4: Anzeigefarben der Farbschlag-Chips auf den Stammbaum-Karten.
*
* Reine Präsentation (angenäherte Fellfarben) — KEINE Genetik. Schlüssel sind
* die Basisnamen aus dem GEN-1-Katalog (src/genetics/catalog.ts BASE_COLORS);
* Modifikatoren („… Schecke“, „… Rex“) werden über den Basisnamen-Präfix
* aufgelöst. Unbekannte Namen → null, die Karte nutzt dann den neutralen Chip.
*/
export interface ChipColor {
readonly bg: string
readonly fg: string
}
const CHIP_COLORS: Record<string, ChipColor> = {
// Colourpoint / C-Serie
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },
Zobel: { bg: '#6b4f3a', fg: '#ffffff' },
// Schimmel
Schwarzschimmel: { bg: '#7a7470', fg: '#ffffff' },
Rotaugenschimmel: { bg: '#c0a99a', fg: '#3b3026' },
// Schwarzäugige Vollfarben
Agouti: { bg: '#b3854d', fg: '#ffffff' },
Schwarz: { bg: '#2f2a26', fg: '#ffffff' },
Silberagouti: { bg: '#b9b3a8', fg: '#3b3026' },
Anthrazit: { bg: '#5a5550', fg: '#ffffff' },
Algierfuchs: { bg: '#c98a3d', fg: '#ffffff' },
Blau: { bg: '#8a93a5', fg: '#ffffff' },
// Rotäugige Vollfarben
Gold: { bg: '#d9a441', fg: '#3b3026' },
Platin: { bg: '#c8c2bd', fg: '#3b3026' },
Goldfuchs: { bg: '#e0b15e', fg: '#3b3026' },
Rotfuchs: { bg: '#b56a3c', fg: '#ffffff' },
'dd Gold': { bg: '#e3c98f', fg: '#3b3026' },
'dd Platin': { bg: '#d9d4cf', fg: '#3b3026' },
}
/** Chipfarbe für einen Farbschlag-Namen (inkl. Modifikator-Suffixen). */
export function chipColorFor(farbschlag: string): ChipColor | null {
const exact = CHIP_COLORS[farbschlag]
if (exact) return exact
// Modifikatoren abgetrennt: längster passender Basisname gewinnt.
let best: ChipColor | null = null
let bestLength = 0
for (const [name, color] of Object.entries(CHIP_COLORS)) {
if (farbschlag.startsWith(name + ' ') && name.length > bestLength) {
best = color
bestLength = name.length
}
}
return best
}

View File

@@ -0,0 +1,38 @@
/**
* FEAT-4: API-gestützte PedigreeSource mit Cache.
*
* Der Cache lebt so lange wie die Stammbaum-Seite gemountet ist und bleibt
* beim Umwurzeln (Tipp auf eine Karte) erhalten — gemeinsame Vorfahren werden
* nur einmal geladen, der Wechsel fühlt sich dadurch unmittelbar an.
*
* 404 → null („unbekannt“); andere Fehler werden NICHT gecacht (erneuter
* Versuch möglich) und an den Aufrufer durchgereicht.
*/
import { ApiError } from '../api/client'
import { getGerbil } from '../api/gerbils'
import { getLitter } from '../api/litters'
import type { Gerbil, Litter } from '../api/types'
import type { PedigreeSource } from './types'
function cached<T>(load: (id: string) => Promise<T>): (id: string) => Promise<T | null> {
const cache = new Map<string, Promise<T | null>>()
return (id: string) => {
let entry = cache.get(id)
if (!entry) {
entry = load(id).catch((err: unknown) => {
if (err instanceof ApiError && err.status === 404) return null
cache.delete(id)
throw err
})
cache.set(id, entry)
}
return entry
}
}
export function createApiPedigreeSource(): PedigreeSource {
return {
getGerbil: cached<Gerbil>(getGerbil),
getLitter: cached<Litter>(getLitter),
}
}

View File

@@ -0,0 +1,50 @@
/**
* FEAT-4: Datenmodell des Ahnenbaums (Stammbaum).
*
* Reines TypeScript-Modul ohne React/DOM — testbar und sprachneutral
* (deutsche Texte kommen ausschließlich aus de.ts, in der UI-Schicht).
*/
import type { Gerbil, Litter } from '../api/types'
/**
* Datenquelle für die Traversierung. In der App von einer gecachten
* API-Implementierung erfüllt (source.ts); in Tests von In-Memory-Maps.
* `null` = nicht vorhanden (z. B. 404) → wird als „unbekannt“ gerendert.
*/
export interface PedigreeSource {
getGerbil(id: string): Promise<Gerbil | null>
getLitter(id: string): Promise<Litter | null>
}
/**
* Position eines Knotens im Ahnenbaum: '' = Wurzeltier, danach pro Generation
* ein Buchstabe — 'v' = Vater, 'm' = Mutter. Beispiel: 'vm' = Großmutter
* väterlicherseits. Der Pfad ist der eindeutige Schlüssel einer POSITION;
* dasselbe Tier kann (bei Inzucht) an mehreren Positionen stehen.
*/
export type PedigreePath = string
export type PedigreeNode = AnimalNode | UnknownNode
export interface AnimalNode {
readonly kind: 'animal'
readonly path: PedigreePath
readonly gerbil: Gerbil
/**
* Eltern: entweder BEIDE gesetzt (geladen; ggf. „unbekannt“-Platzhalter)
* oder BEIDE undefined (Ladegrenze der aktuellen Generationstiefe).
*/
readonly father?: PedigreeNode
readonly mother?: PedigreeNode
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
readonly expandable: boolean
}
/**
* Platzhalter für einen unbekannten Vorfahren. Hält die Geometrie der
* Ahnentafel symmetrisch (Zertifikats-Optik), hat selbst keine Eltern.
*/
export interface UnknownNode {
readonly kind: 'unknown'
readonly path: PedigreePath
}