Compare commits

..

1 Commits

Author SHA1 Message Date
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
13 changed files with 64 additions and 570 deletions

View File

@@ -361,26 +361,6 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 201, contract) return json(route, 201, contract)
} }
// FEAT-NAMEGEN: /names/suggest
if (path === '/names/suggest' && method === 'GET') {
if (!db.namesConfigured) {
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
}
const letter = url.searchParams.get('letter')?.toUpperCase()
const allSuggestions = [
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
]
const result = letter
? allSuggestions.filter((s) => s.name.startsWith(letter))
: allSuggestions
return json(route, 200, result)
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -74,8 +74,6 @@ export interface MockDb {
// ABGABE: Verträge + KI-Inserat-Flag // ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[] contracts: MockContract[]
saleAdConfigured: boolean saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
} }
function gerbil( function gerbil(
@@ -294,6 +292,5 @@ export function seedDb(): MockDb {
mailConfigured: true, mailConfigured: true,
contracts: [], contracts: [],
saleAdConfigured: true, saleAdConfigured: true,
namesConfigured: true,
} }
} }

View File

@@ -1,59 +0,0 @@
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.namegen
const tf = de.pages.gerbils.form
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
// Panel öffnen
await page.getByRole('button', { name: t.button }).click()
await expect(page.getByText(t.panelTitle)).toBeVisible()
// Vorschläge laden
await page.getByRole('button', { name: t.loadButton }).click()
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
})
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
// Klick auf Vorschlag 'Fenrir'
await page.getByRole('button', { name: 'Fenrir' }).click()
// Panel geschlossen, Name-Feld befüllt
await expect(page.getByText(t.panelTitle)).toBeHidden()
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
})
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByLabel(t.letterLabel).fill('F')
await page.getByRole('button', { name: t.loadButton }).click()
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
// Artemis (A) nicht sichtbar
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
})
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
skipUnlessMock()
if (mockDb) mockDb.namesConfigured = false
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByText(t.keyMissing)).toBeVisible()
})

View File

@@ -172,27 +172,3 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
await page.goto('/rennmaeuse/kruemel/bearbeiten') await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true') 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

@@ -1,78 +0,0 @@
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
import { describe, expect, it } from 'vitest'
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
describe('buildSuggestPath', () => {
it('includes uppercased letter', () => {
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
expect(path).toContain('letter=F')
})
it('omits letter when blank', () => {
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('omits letter when only whitespace', () => {
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('includes gender', () => {
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
expect(path).toContain('gender=male')
})
it('omits gender for empty string', () => {
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
expect(path).not.toContain('gender=')
})
it('joins multiple usages without encoding commas', () => {
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
expect(path).toContain('usages=norn,mythg')
})
it('omits usages when array is empty', () => {
const path = buildSuggestPath({ usages: [] })
expect(path).not.toContain('usages=')
})
it('defaults count to 6', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toContain('count=6')
})
it('uses provided count', () => {
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
expect(path).toContain('count=8')
})
it('starts with /names/suggest', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toMatch(/^\/names\/suggest\?/)
})
})
describe('NAMEGEN_USAGES', () => {
it('contains all 5 expected culture codes', () => {
const codes = NAMEGEN_USAGES.map((u) => u.code)
expect(codes).toContain('norn')
expect(codes).toContain('japa')
expect(codes).toContain('mythg')
expect(codes).toContain('ger')
expect(codes).toContain('arb')
expect(codes).toContain('disney')
expect(codes).toContain('pokemon')
expect(codes).toContain('encities')
expect(codes).toContain('hrcities')
expect(codes).toContain('usstates')
expect(codes).toHaveLength(10)
})
it('every usage has a non-empty label', () => {
for (const u of NAMEGEN_USAGES) {
expect(u.label.length).toBeGreaterThan(0)
}
})
})

View File

@@ -1,45 +0,0 @@
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
import { api } from './client'
export interface NameSuggestion {
name: string
meaning: string
origin: string
}
export const NAMEGEN_USAGES = [
{ code: 'norn', label: 'Nordisch' },
{ code: 'japa', label: 'Japanisch' },
{ code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' },
{ code: 'disney', label: 'Disney' },
{ code: 'pokemon', label: 'Pokémon' },
{ code: 'encities', label: 'Englische Städte' },
{ code: 'hrcities', label: 'Kroatische Städte' },
{ code: 'usstates', label: 'US-Bundesstaaten' },
] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
export interface SuggestNamesParams {
letter?: string
gender?: string
usages: NamegenUsageCode[]
count?: number
}
/** Exported for unit tests — builds the query path without a network call. */
export function buildSuggestPath(params: SuggestNamesParams): string {
const parts: string[] = []
const letter = params.letter?.trim().toUpperCase()
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
parts.push(`count=${params.count ?? 6}`)
return `/names/suggest?${parts.join('&')}`
}
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
return api.get<NameSuggestion[]>(buildSuggestPath(params))
}

View File

@@ -1,114 +0,0 @@
.namegen-name-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.namegen-name-row .input {
flex: 1;
min-width: 0;
}
.namegen-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-bg, #fff);
}
.namegen-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-weight: 600;
}
.namegen-panel__filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.namegen-panel__letter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-panel__letter-input {
width: 5rem;
}
.namegen-panel__usages {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-usages-grid {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
}
.namegen-usages-grid label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.9rem;
cursor: pointer;
}
.namegen-suggestions {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
border-top: 1px solid var(--color-border);
}
.namegen-suggestion {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.namegen-suggestion:last-child {
border-bottom: none;
}
.namegen-suggestion__pick {
font-weight: 600;
color: var(--color-accent, #2563eb);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
font-size: inherit;
font-family: inherit;
min-width: 6rem;
}
.namegen-suggestion__pick:hover {
text-decoration: underline;
}
.namegen-suggestion__meaning {
flex: 1;
font-size: 0.88rem;
color: var(--color-text-muted);
min-width: 8rem;
}
.namegen-suggestion__origin {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}

View File

@@ -1,132 +0,0 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { ApiError, errorCode } from '../api/client'
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
import './NameSuggestPanel.css'
interface NameSuggestPanelProps {
gender: string
onPick: (name: string) => void
onClose: () => void
}
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
const t = de.namegen
const [letter, setLetter] = useState('')
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
const [loading, setLoading] = useState(false)
const [keyMissing, setKeyMissing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
const [fetched, setFetched] = useState(false)
function toggleUsage(code: NamegenUsageCode) {
setUsages((prev) => {
const next = new Set(prev)
if (next.has(code)) next.delete(code)
else next.add(code)
return next
})
}
async function load() {
setLoading(true)
setError(null)
setKeyMissing(false)
setSuggestions([])
try {
const result = await suggestNames({
letter: letter.trim() || undefined,
gender: gender || undefined,
usages: [...usages] as NamegenUsageCode[],
count: 6,
})
setSuggestions(result)
setFetched(true)
} catch (err) {
if (errorCode(err) === 'NamesKeyMissing') {
setKeyMissing(true)
} else {
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
}
} finally {
setLoading(false)
}
}
return (
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
<div className="namegen-panel__header">
<span>{t.panelTitle}</span>
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
</button>
</div>
<div className="namegen-panel__filters">
<div className="namegen-panel__letter">
<label htmlFor="namegen-letter">{t.letterLabel}</label>
<input
id="namegen-letter"
className="input namegen-panel__letter-input"
value={letter}
onChange={(e) => setLetter(e.target.value)}
placeholder={t.letterPlaceholder}
maxLength={1}
/>
</div>
<div className="namegen-panel__usages">
<span>{t.usagesLabel}</span>
<div className="namegen-usages-grid">
{NAMEGEN_USAGES.map(({ code, label }) => (
<label key={code}>
<input
type="checkbox"
checked={usages.has(code)}
onChange={() => toggleUsage(code)}
/>
{label}
</label>
))}
</div>
</div>
</div>
<button
type="button"
className="btn btn--primary"
onClick={load}
disabled={loading || usages.size === 0}
>
{loading ? t.loading : t.loadButton}
</button>
{keyMissing && <p className="muted">{t.keyMissing}</p>}
{error && <p className="error-text">{error}</p>}
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{suggestions.length > 0 && (
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
{suggestions.map((s, i) => (
<li key={`${s.name}-${i}`} className="namegen-suggestion">
<button
type="button"
className="namegen-suggestion__pick"
onClick={() => onPick(s.name)}
>
{s.name}
</button>
<span className="namegen-suggestion__meaning">{s.meaning}</span>
<span className="namegen-suggestion__origin">{s.origin}</span>
</li>
))}
</ul>
)}
</div>
)
}

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)', () => { 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( 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', () => { 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') 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( 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 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', () => { 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 Algierfuchs')
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs') 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: '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: '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' }, { 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). // GEN-3a: efef base (agouti, 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-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: '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: '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' }, { 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' } }, { 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 // GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here. // 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: '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: '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' }, { 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 * 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 * 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 * 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/ * a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'. * allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the * for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
* raw pair, which runs before this.) * 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 { function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the // 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)) const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
if (locus === 'E') { if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'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('E', x, y) // E/ef, E/e -> 'E'
} }
return dominantAllele(locus, x, y) 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 * E-locus family: used to scope the catalog search to E-aware entries.
* when other loci are unknown (so genotypes never fall through to "Unbekannt"). * Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now) * implies a non-dominant extension pair, or null for full-extension/unknown.
* Returns null when E is dominant (full colour) or fully 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 { function eFamily(g: Genotype): string | null {
const [x, y] = g.E const [x, y] = g.E
@@ -211,7 +219,10 @@ function baseColourFor(g: Genotype): string | null {
const base = family const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null) ? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => 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) 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 } return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
} }
const name = [baseName, ...modifiers].join(' ') const name = [baseName, ...modifiers].join(' ')

View File

@@ -2,7 +2,6 @@ import { useMemo, useState, type ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { getGerbil, updateGerbil } from '../api/gerbils' import { getGerbil, updateGerbil } from '../api/gerbils'
import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
@@ -56,10 +55,6 @@ export default function GerbilDetailPage() {
const enclosures = useApi(() => listEnclosures(), []) const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), []) const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), []) const litters = useApi(() => listLitters(), [])
const parentLitters = useApi(
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const colorName = useMemo( const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])), () => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
@@ -242,32 +237,6 @@ export default function GerbilDetailPage() {
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>} {saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div> </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> <h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => ( {(['photos', 'health', 'weight'] as const).map((key) => (

View File

@@ -8,8 +8,6 @@ import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels' import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString } from '../genetics' import { fromDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel'
import '../components/NameSuggestPanel.css'
interface FormState { interface FormState {
name: string name: string
@@ -113,7 +111,6 @@ export default function GerbilFormPage() {
const [form, setForm] = useState<FormState>(EMPTY) const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({}) const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [initializedFor, setInitializedFor] = useState<string | null>(null) const [initializedFor, setInitializedFor] = useState<string | null>(null)
const [showNameSuggest, setShowNameSuggest] = useState(false)
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id]) const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
const colorVarieties = useApi(() => listColorVarieties(), []) const colorVarieties = useApi(() => listColorVarieties(), [])
@@ -200,33 +197,16 @@ export default function GerbilFormPage() {
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2> <h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
<form className="form" onSubmit={onSubmit} noValidate> <form className="form" onSubmit={onSubmit} noValidate>
<div className="field"> <label className="field">
<label htmlFor="gerbil-name">{t.fields.name} *</label> <span>{t.fields.name} *</span>
<div className="namegen-name-row">
<input <input
id="gerbil-name"
className="input" className="input"
value={form.name} value={form.name}
onChange={(e) => set('name', e.target.value)} onChange={(e) => set('name', e.target.value)}
aria-invalid={Boolean(errors.name)} aria-invalid={Boolean(errors.name)}
/> />
<button
type="button"
className="btn"
onClick={() => setShowNameSuggest((v) => !v)}
>
{de.namegen.button}
</button>
</div>
{errors.name && <small className="error-text">{errors.name}</small>} {errors.name && <small className="error-text">{errors.name}</small>}
</div> </label>
{showNameSuggest && (
<NameSuggestPanel
gender={form.gender}
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
onClose={() => setShowNameSuggest(false)}
/>
)}
<label className="field"> <label className="field">
<span>{t.fields.gender} *</span> <span>{t.fields.gender} *</span>

View File

@@ -105,11 +105,6 @@ export const de = {
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.', tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.', notFound: 'Diese Rennmaus wurde nicht gefunden.',
moreData: 'Weitere Daten', 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) // Formular (anlegen/bearbeiten)
form: { form: {
@@ -818,19 +813,6 @@ export const de = {
resetButton: 'Filter zurücksetzen', resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen', closeButton: 'Schließen',
}, },
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
namegen: {
button: 'Name vorschlagen',
panelTitle: 'Namensvorschläge',
letterLabel: 'Anfangsbuchstabe',
letterPlaceholder: 'z. B. A',
usagesLabel: 'Herkunftskultur',
loadButton: 'Vorschläge laden',
loading: 'Lade Vorschläge …',
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
close: 'Schließen',
},
} as const } as const
export type Strings = typeof de export type Strings = typeof de