From c35a6eca0441e72e4908eb10b033af8a0c4ec682 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 10:45:15 +0200 Subject: [PATCH] =?UTF-8?q?GEN-3c:=20fix=20'Unbekannter=20Farbschlag'=20?= =?UTF-8?q?=E2=80=94=20unknown=3Dwildcard=20in=20matches()=20+=20E-pair-aw?= =?UTF-8?q?are=20family=20fallback=20(eef=3DFuchsschimmel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/genetics/__tests__/genetics.test.ts | 35 ++++++++- gerbil-manager-web/src/genetics/catalog.ts | 71 +++++++++++++++---- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index 40a6608..1cee92c 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -173,8 +173,8 @@ describe('Farbschlag catalog', () => { }) it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => { - // Colourpoint marked + dilute + grey combo not in the catalog. - const match = farbschlagFor(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere')) + // E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo. + const match = farbschlagFor(fromDisplayString('aa cchmcchm dd EE gg pp spsp rere')) expect(match.unknown).toBe(true) expect(match.name).toBe('Unbekannter Farbschlag') }) @@ -222,7 +222,7 @@ describe('Farbschlag catalog', () => { it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { expect(genotypeToFarbschlag(wildType())).toBe('Agouti') expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin') - expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))).toBe( + expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd EE gg pp spsp rere'))).toBe( 'Unbekannter Farbschlag', ) }) @@ -350,3 +350,32 @@ describe('GEN-3a: Schimmel catalog fix (breeder C5)', () => { expect(BASE_COLORS.some((e) => e.name === 'Schwarzschimmel')).toBe(false) }) }) + +describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => { + it("accepts '-' input, stores '?', displays '-'", () => { + const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere') + expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?' + expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-' + }) + it("'?' and '-' inputs are equivalent", () => { + expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe( + 'Aa C- DD EE GG Pp spsp rere', + ) + }) +}) + +describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => { + it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => { + expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe( + 'Fuchsschimmel', + ) + }) + it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => { + expect(farbschlagFor(fromDisplayString('a- C- D- ee G- P-')).unknown).toBe(false) + expect(farbschlagFor(fromDisplayString('A- C- D- efef G- P-')).unknown).toBe(false) + }) + it('unknown allele no longer blocks a match (wildcard, never Unbekannt)', () => { + // C unknown must not force Unbekannt — it resolves to SOME named variety. + expect(farbschlagFor(fromDisplayString('aa C- DD EE GG PP spsp rere')).unknown).toBe(false) + }) +}) diff --git a/gerbil-manager-web/src/genetics/catalog.ts b/gerbil-manager-web/src/genetics/catalog.ts index 7cd7c88..360f056 100644 --- a/gerbil-manager-web/src/genetics/catalog.ts +++ b/gerbil-manager-web/src/genetics/catalog.ts @@ -20,9 +20,15 @@ * meta rows dropped, 17 matched the frozen names). Genotypes normalized from * portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown). */ -import { LOCUS_ORDER, type LocusKey } from './loci' -import { makeGenotype, toDisplayString, wildType, type AllelePair, type Genotype } from './genotype' -import { phenotypeTokens, type PhenotypeTokens } from './phenotype' +import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci' +import { + makeGenotype, + toDisplayString, + wildType, + WILDCARD, + type AllelePair, + type Genotype, +} from './genotype' export interface FarbschlagEntry { /** German variety name. FROZEN for the original 18 (DB keys). */ @@ -138,25 +144,64 @@ export interface FarbschlagMatch { readonly unknown: boolean } -function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean { - return (Object.keys(entry.tokens) as LocusKey[]).every( - (locus) => tokens[locus] === entry.tokens[locus], - ) +/** + * Expressed token at a locus, or null when UNKNOWN (a '?' allele) — null acts as + * a wildcard in matching. The E locus is PAIR-aware so the Fuchs/Schimmel family + * is distinguishable: ee->'e', e/ef->'eef' (Fuchsschimmel), ef/ef->'ef' (Schimmel). + */ +function locusToken(g: Genotype, locus: LocusKey): string | null { + const [x, y] = g[locus] + if (x === WILDCARD || y === WILDCARD) return null + 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' + return dominantAllele('E', x, y) // E/ef, E/e -> 'E' + } + return dominantAllele(locus, x, y) +} + +function matches(g: Genotype, entry: FarbschlagEntry): boolean { + // Unknown loci (null token) match anything (treated as wildcard, GEN-3c). + return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => { + const t = locusToken(g, locus) + return t === null || t === entry.tokens[locus] + }) +} + +/** + * 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. + */ +function eFamily(g: Genotype): string | null { + const [x, y] = g.E + if (x === 'e' && y === 'e') return 'Fuchs' + if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel' + if (x === 'ef' && y === 'ef') return 'Schimmel' + if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs' + return null } /** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */ export function farbschlagFor(g: Genotype): FarbschlagMatch { - const tokens = phenotypeTokens(g) - const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null + const family = eFamily(g) + // When the animal is a Fuchs/Schimmel-family genotype, only entries that + // explicitly pin the E locus may name it (so e.g. eef can't masquerade as a + // C-series white via wildcard C); otherwise fall back to the family name. + const base = family + ? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null) + : (BASE_COLORS.find((e) => matches(g, e)) ?? null) const modifiers: string[] = [] - if (tokens.Sp === 'Sp') modifiers.push('Schecke') - if (tokens.Re === 'Re') modifiers.push('Rex') + if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke') + if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex') - if (!base) { + const baseName = base?.name ?? family + if (!baseName) { return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true } } - const name = [base.name, ...modifiers].join(' ') + const name = [baseName, ...modifiers].join(' ') return { name, base, unknown: false } }