Compare commits
5 Commits
433f9ee592
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ab6bf072 | |||
| 7fe538dd5d | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be |
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(' ')
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user