diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index b6b650b..bd5c7ec 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -174,7 +174,7 @@ describe('Farbschlag catalog', () => { it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => { // 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')) + const match = farbschlagFor(fromDisplayString('aa CC 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 CC dd EE gg pp spsp rere'))).toBe( 'Unbekannter Farbschlag', ) }) @@ -399,3 +399,33 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => { ) }) }) + +describe('GEN-3e: C-locus colourpoint naming', () => { + const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s)) + + it('A- + cchm/cchm -> CP- (CP-Silberagouti example)', () => { + expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti') + expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti') + }) + + it('A- + cchm/ch -> CP-', () => { + expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti') + }) + + it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => { + expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder') + expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam') + expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel') + expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell') + }) + + it('chch stays Hermelin (aa) / Himalaya (A-); full C => no CP', () => { + expect(name('aa chch DD EE GG PP spsp rere')).toBe('Hermelin') + expect(name('AA chch DD EE GG PP spsp rere')).toBe('Himalaya') + expect(name('AA CC DD EE GG PP spsp rere')).toBe('Agouti') // one full C -> no colourpoint + }) + + it('colourpoint composes with the Schecke modifier', () => { + expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke') + }) +}) diff --git a/gerbil-manager-web/src/genetics/catalog.ts b/gerbil-manager-web/src/genetics/catalog.ts index 0c2e468..a1e3c00 100644 --- a/gerbil-manager-web/src/genetics/catalog.ts +++ b/gerbil-manager-web/src/genetics/catalog.ts @@ -191,25 +191,61 @@ function eFamily(g: Genotype): string | null { } /** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */ -export function farbschlagFor(g: Genotype): FarbschlagMatch { +/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */ +function resolvedPair(g: Genotype, locus: LocusKey): [string, string] { + 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)) + return [x, y] +} + +/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */ +function baseColourFor(g: Genotype): string | 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) + return base?.name ?? family +} +/** + * GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative). + * Returns the colourpoint name, or null when it doesn't apply (full C present, + * or chch — which the base matcher names Hermelin/Himalaya, preserving both). + * aa cchm/cchm -> Marder | aa cchm/ch -> Siam + * aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell + * A- cchm/cchm | cchm/ch -> CP- (base computed as if C were full) + */ +function colourpointName(g: Genotype): string | null { + const c = resolvedPair(g, 'C') + if (c.includes('C')) return null // a full C allele => full colour, no CP + if (c[0] === 'ch' && c[1] === 'ch') return null // chch -> base matcher (Hermelin/Himalaya) + // Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch). + const bothCchm = c[0] === 'cchm' && c[1] === 'cchm' + const agouti = resolvedPair(g, 'A').includes('A') + if (!agouti) { + const [g1, g2] = resolvedPair(g, 'G') + const grey = g1 === 'g' && g2 === 'g' + if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell' + return bothCchm ? 'Marder' : 'Siam' + } + // A- colourpoint -> CP-, base as if C were full. + const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] })) + return base ? `CP-${base}` : null +} + +export function farbschlagFor(g: Genotype): FarbschlagMatch { const modifiers: string[] = [] if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke') if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex') - const baseName = base?.name ?? family + const baseName = colourpointName(g) ?? baseColourFor(g) if (!baseName) { return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true } } const name = [baseName, ...modifiers].join(' ') - return { name, base, unknown: false } + return { name, base: null, unknown: false } } /**