diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index 1cee92c..b6b650b 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -379,3 +379,23 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () expect(farbschlagFor(fromDisplayString('aa C- DD EE GG PP spsp rere')).unknown).toBe(false) }) }) + +describe('GEN-3d: dominance tiebreak for unknown loci', () => { + it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => { + // 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya. + const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere')) + expect(name).toBe('Schwarz') + expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name) + }) + + it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => { + // 'sp-' (one sp + unknown) -> sp/sp -> no Schecke (naive most-dominant would wrongly add it). + expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti') + }) + + it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => { + expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe( + 'Fuchsschimmel', + ) + }) +}) diff --git a/gerbil-manager-web/src/genetics/catalog.ts b/gerbil-manager-web/src/genetics/catalog.ts index 360f056..0c2e468 100644 --- a/gerbil-manager-web/src/genetics/catalog.ts +++ b/gerbil-manager-web/src/genetics/catalog.ts @@ -20,7 +20,7 @@ * 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, dominantAllele, type LocusKey } from './loci' +import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci' import { makeGenotype, toDisplayString, @@ -145,13 +145,22 @@ export interface FarbschlagMatch { } /** - * 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). + * 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.) */ -function locusToken(g: Genotype, locus: LocusKey): string | null { - const [x, y] = g[locus] - if (x === WILDCARD || y === WILDCARD) return null +function locusToken(g: Genotype, locus: LocusKey): string { + // Default an unknown allele to the WILD-TYPE reading: most-dominant for the + // colour loci (unknown-C => full-colour 'C', not a white), but the recessive + // UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke). + const alleles = LOCI[locus].alleles + const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls' + const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0] + 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' @@ -161,11 +170,9 @@ function locusToken(g: Genotype, locus: LocusKey): string | null { } 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] - }) + return (Object.keys(entry.tokens) as LocusKey[]).every( + (locus) => locusToken(g, locus) === entry.tokens[locus], + ) } /**