Merge feature/gen-3e [god-QA pending]

This commit is contained in:
2026-06-06 11:10:52 +02:00
2 changed files with 74 additions and 8 deletions

View File

@@ -174,7 +174,7 @@ describe('Farbschlag catalog', () => {
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => { it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
// E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo. // 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.unknown).toBe(true)
expect(match.name).toBe('Unbekannter Farbschlag') expect(match.name).toBe('Unbekannter Farbschlag')
}) })
@@ -222,7 +222,7 @@ describe('Farbschlag catalog', () => {
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
expect(genotypeToFarbschlag(wildType())).toBe('Agouti') expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin') 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', '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-<base colour> (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-<base colour>', () => {
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')
})
})

View File

@@ -191,25 +191,61 @@ function eFamily(g: Genotype): string | null {
} }
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */ /** 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) 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 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-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 colour> (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 colour>, 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[] = [] const modifiers: string[] = []
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke') if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex') if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
const baseName = base?.name ?? family const baseName = colourpointName(g) ?? baseColourFor(g)
if (!baseName) { if (!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(' ')
return { name, base, unknown: false } return { name, base: null, unknown: false }
} }
/** /**