Compare commits

...

5 Commits

Author SHA1 Message Date
c8ab6bf072 STAMMBAUM-OPEN: Name in Ahnen-Karte ist Link zur Tier-Detailseite
- PedigreeCard: <span className=nametext> → <Link to=/rennmaeuse/{id}> mit e.stopPropagation() (Re-Root bleibt auf Karten-Bereich außerhalb des Namens)
- stammbaum.css: .pedigree-card__nametext color:inherit + text-decoration:none; hover→underline
- Kein Link im Drucklayout (PrintPedigree unverändert)
- e2e: href=/rennmaeuse/fridolin verifiziert + Keyboard-Navigation (fokus+Enter) öffnet Detailseite

Gate: vitest 108/108, e2e 162/162, tsc clean
2026-06-06 21:54:33 +02:00
7fe538dd5d Merge feature/gen-4 (GEN-4b): 7 Farbarten als Engine-Guard (keine Farbschläge) + Schimmel-Naming-Fix
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
ff42341: CATEGORY_NAMES-Guard (Standard/Colourpoint/Dilute/Fuchs/Fuchsschimmel/Schimmel/CP-Dilute nie als Ergebnis, FK=0);
locusToken E ef/e→'ef' (het-Schimmel matchen konkrete Katalog-Eintraege statt Family-Fallback 'Fuchsschimmel');
baseColourFor Family-Fallback→null; Orangeschimmel A-Restriktion (aa→Kohlfuchsschimmel). Frontend-only. vitest 96, e2e 148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:49:20 +02:00
ff42341c47 GEN-4 Addendum: 7 Farbarten-Kategorien blockiert + locusToken ef/e-Fix
catalog.ts:
- locusToken E: ef/e -> 'ef' (phenotypisch ausgedrücktes Allel; enables
  catalog-Match fuer het Schimmel-Tiere wie Kohlfuchsschimmel/Orangeschimmel).
  Vorher: 'eef' matchte keinen Katalogeintrag -> Family-Fallback 'Fuchsschimmel'.
- baseColourFor: Family-Fallback entfernt (null statt family-Name) — Familie
  ist nur noch Katalog-Suchfilter, nie direkter Ausgabewert.
- CATEGORY_NAMES Guard in farbschlagFor: Fuchs/Fuchsschimmel/Schimmel/Standard/
  Colourpoint/Dilute/Colourpoint Dilute -> Unbekannter Farbschlag wenn Engine
  diese Namen zurueckgeben wuerde.
- Orangeschimmel: A:'A' hinzugefuegt (Agouti-Einschraenkung), damit aa-ef-Tiere
  durch zu Kohlfuchsschimmel fallen.
- FK-Check: Keine der 7 Kategorie-Namen sind in BASE_COLORS -> 0 FK-Risiko.

genetics.test.ts:
- 'eef with unknown other loci': erwartet jetzt 'Kohlfuchsschimmel' (korrekt!)
- GEN-4 Farbarten-Fixture: Kategorie-Guard-Test + FK-Check-Assert.
Gate: build ✓  eslint ✓  vitest 96/96 ✓  e2e 148/148 ✓
2026-06-06 21:42:15 +02:00
90f998d189 Merge feature/animal-litters (ANIMAL-LITTERS): 'Wuerfe als Elternteil' auf Tier-Detailseite
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilDetailPage: Sektion listet Wuerfe wo fatherId|motherId==Tier (Gridify OR-Filter),
je Link /wuerfe/{id} + Vater/Mutter-Badge + Datum + totalBorn; Leer-Zustand. Rueckweg Tier→Wuerfe.
Frontend-only. vitest 104, e2e 160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:42:04 +02:00
d5f4be76be ANIMAL-LITTERS: Tier-Detailseite zeigt Würfe dieses Tiers als Elternteil
- GerbilDetailPage: listLittersPaged({filter: fatherId={id}|motherId={id}}) + neue Sektion 'Würfe als Elternteil' vor den Tabs; je Wurf: Link /wuerfe/{id}, Vater/Mutter-Badge, Datum, Wurfstärke; Leer-Zustand wenn keine Würfe
- de.ts: parentLittersTitle, parentLittersEmpty, parentLittersRoleVater/Mutter (append-only in detail-Sektion)
- e2e: +2 Tests (Fridolin hat Wurf K als Vater; Krümel hat Leer-Zustand)

Gate: vitest 104/104, e2e 160/160, tsc clean
2026-06-06 21:39:14 +02:00
8 changed files with 145 additions and 18 deletions

View File

@@ -54,6 +54,23 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
})
test('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
const nameLink = page.getByRole('link', { name: 'Fridolin' })
await expect(nameLink).toBeVisible()
// href korrekt gesetzt
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
await nameLink.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
})
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub/stammbaum')

View File

@@ -172,3 +172,27 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
})
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
// Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible()
// Vater-Badge sichtbar
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
// Navigation zum Wurf funktioniert
await litterLink.first().click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
})

View File

@@ -377,9 +377,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
})
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Fuchsschimmel',
'Kohlfuchsschimmel',
)
})
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
@@ -405,9 +407,11 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
})
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Fuchsschimmel',
'Kohlfuchsschimmel',
)
})
})
@@ -561,6 +565,21 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
})
it('Farbarten (categories) never appear as computed results', () => {
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
for (const cat of CATS) {
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
}
})
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')

View File

@@ -78,8 +78,9 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
@@ -94,6 +95,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
@@ -153,10 +155,12 @@ export interface FarbschlagMatch {
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
* raw pair, which runs before this.)
* a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
* variety specifically.
*/
function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
@@ -168,7 +172,9 @@ function locusToken(g: Genotype, locus: LocusKey): string {
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
// enables catalog entries like Kohlfuchsschimmel to match het animals).
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
}
return dominantAllele(locus, x, y)
@@ -181,10 +187,12 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
}
/**
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
* Returns null when E is dominant (full colour) or fully unknown.
* E-locus family: used to scope the catalog search to E-aware entries.
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
* implies a non-dominant extension pair, or null for full-extension/unknown.
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
* They are ONLY used here as catalog-search filters; they must NEVER appear as
* computed farbschlag output (the farbschlagFor category guard blocks them).
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
@@ -211,7 +219,10 @@ function baseColourFor(g: Genotype): string | null {
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
return base?.name ?? family
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
return base?.name ?? null
}
/**
@@ -268,7 +279,15 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
}
const baseName = colourpointName(g) ?? baseColourFor(g)
if (!baseName) {
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
// a computed Farbschlag output. If baseName is a category label, treat as
// Unbekannt instead of leaking an invalid name into the UI.
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
'Standard', 'Colourpoint', 'Dilute',
'Fuchs', 'Fuchsschimmel', 'Schimmel',
'Colourpoint Dilute',
])
if (!baseName || CATEGORY_NAMES.has(baseName)) {
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
}
const name = [baseName, ...modifiers].join(' ')

View File

@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de'
import { getGerbil, updateGerbil } from '../api/gerbils'
import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), [])
const parentLitters = useApi(
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div>
<h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
<p className="muted">{t.detail.parentLittersEmpty}</p>
)}
{(parentLitters.data?.items.length ?? 0) > 0 && (
<ul className="card-list">
{(parentLitters.data?.items ?? []).map((litter) => (
<li key={litter.id}>
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
<span className="gerbil-card__name">{litter.name}</span>
<span className="badge badge--active">
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
</span>
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
{litter.totalBorn !== null && (
<span className="gerbil-card__meta">
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
</Link>
</li>
))}
</ul>
)}
<h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => (

View File

@@ -377,7 +377,13 @@ function PedigreeCard({
<div className="pedigree-card__body">
<div className="pedigree-card__name">
<SexIcon gender={g.gender} />
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
<Link
to={`/rennmaeuse/${g.id}`}
className="pedigree-card__nametext"
onClick={(e) => e.stopPropagation()}
>
{g.name || de.pages.gerbils.nameless}
</Link>
</div>
{farbschlag && (
<span

View File

@@ -134,6 +134,12 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: inherit;
text-decoration: none;
}
.pedigree-card__nametext:hover {
text-decoration: underline;
}
.pedigree-card__sex--male {

View File

@@ -105,6 +105,11 @@ export const de = {
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.',
moreData: 'Weitere Daten',
// ANIMAL-LITTERS: Würfe als Elternteil
parentLittersTitle: 'Würfe als Elternteil',
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter',
},
// Formular (anlegen/bearbeiten)
form: {