diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index 7180a70..a0a69c1 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -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') diff --git a/gerbil-manager-web/src/genetics/catalog.ts b/gerbil-manager-web/src/genetics/catalog.ts index 786f3f9..817642c 100644 --- a/gerbil-manager-web/src/genetics/catalog.ts +++ b/gerbil-manager-web/src/genetics/catalog.ts @@ -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 = 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(' ')