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 }
}
/**