import { describe, expect, it } from 'vitest'
import {
add,
divide,
frac,
multiply,
toNumber,
toString,
equals,
} from '../fraction'
import {
fromDisplayString,
makeGenotype,
toDisplayString,
toJSON,
fromJSON,
wildType,
extractGenotypeFlags,
displayGenotypeSafe,
} from '../genotype'
import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
import {
farbschlagFor,
genotypeToFarbschlag,
representativeGenotype,
BASE_COLORS,
CATALOG,
CATALOG_SIZE,
} from '../catalog'
/** The first 18 entries are the frozen contract names (must round-trip exactly). */
const FROZEN_COUNT = 17
import { breed } from '../breed'
import { GeneticsWarningCode } from '../warnings'
describe('Fraction', () => {
it('reduces and does exact arithmetic', () => {
expect(toString(frac(2, 4))).toBe('1/2')
expect(toString(add(frac(1, 4), frac(1, 4)))).toBe('1/2')
expect(toString(multiply(frac(1, 2), frac(1, 2)))).toBe('1/4')
expect(toString(divide(frac(1, 4), frac(3, 4)))).toBe('1/3')
expect(equals(frac(3, 6), frac(1, 2))).toBe(true)
})
})
describe('Genotype serialization', () => {
it('round-trips display string <-> structured form', () => {
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
})
it('parses multi-char C-series alleles via maximal munch', () => {
const g = fromDisplayString('aa cchmch DD EE GG PP spsp rere')
expect(g.C).toEqual(['cchm', 'ch'])
})
it('orders allele pairs most-dominant-first', () => {
const g = makeGenotype({
A: ['a', 'A'],
C: ['ch', 'C'],
D: ['D', 'D'],
E: ['E', 'E'],
G: ['G', 'G'],
P: ['p', 'P'],
Sp: ['sp', 'sp'],
Re: ['re', 're'],
Sls: ['sl', 'sl'],
})
expect(g.A).toEqual(['A', 'a'])
expect(g.C).toEqual(['C', 'ch'])
expect(g.P).toEqual(['P', 'p'])
})
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
})
})
describe('Punnett single locus', () => {
it('Aa x Aa -> 1/4 AA, 1/2 Aa, 1/4 aa', () => {
const dist = combineLocus('A', ['A', 'a'], ['A', 'a'])
expect(toString(dist.get('AA')!.probability)).toBe('1/4')
expect(toString(dist.get('Aa')!.probability)).toBe('1/2')
expect(toString(dist.get('aa')!.probability)).toBe('1/4')
})
it('AA x aa -> all Aa', () => {
const dist = combineLocus('A', ['A', 'A'], ['a', 'a'])
expect(dist.size).toBe(1)
expect(toString(dist.get('Aa')!.probability)).toBe('1')
})
})
describe('Worked example from research report', () => {
// Agouti AA CC DD EE GG PP x Platin aa CC DD EE GG pp -> all Aa Cc Dd Ee Gg Pp
// (every locus where parents differ becomes heterozygous with prob 1)
it('Agouti x Platin yields a single uniform heterozygous genotype', () => {
const father = fromDisplayString('AA CC DD EE GG PP spsp rere')
const mother = fromDisplayString('aa CC DD EE GG pp spsp rere')
const result = breed(father, mother)
expect(result.offspring).toHaveLength(1)
const only = result.offspring[0]
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
expect(only.probability.text).toBe('1')
expect(result.warnings).toHaveLength(0)
})
})
describe('Schecke × Schecke lethality (SpSp)', () => {
it('removes SpSp, renormalises, and warns ~25% fewer young', () => {
// Spsp x Spsp at the Sp locus -> 1/4 SpSp (lethal), 1/2 Spsp, 1/4 spsp.
// After removing SpSp the survivors renormalise to 2/3 Spsp : 1/3 spsp.
const father = fromDisplayString('AA CC DD EE GG PP Spsp rere')
const mother = fromDisplayString('AA CC DD EE GG PP Spsp rere')
const result = breed(father, mother)
// No surviving genotype is SpSp.
expect(result.offspring.every((o) => !o.genotype.includes('SpSp'))).toBe(true)
// Probabilities renormalise to sum to exactly 1.
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
expect(sum).toBeCloseTo(1, 10)
// Survivors: 2/3 checkered (Spsp), 1/3 non-checkered (spsp).
const spsp = result.offspring.find((o) => o.genotype.includes('Spsp'))!
const homRec = result.offspring.find((o) => o.genotype.includes('spsp'))!
expect(spsp.probability.text).toBe('2/3')
expect(homRec.probability.text).toBe('1/3')
// Warning emitted, carrying the 1/4 young-lost stat.
const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.ScheckeLethal)
expect(warn).toBeDefined()
expect(warn!.detail?.youngLostFraction).toBe('1/4')
})
})
describe('Rex × Rex semi-lethality (ReRe)', () => {
it('keeps ReRe but emits a semi-lethal warning', () => {
const father = fromDisplayString('AA CC DD EE GG PP spsp Rere')
const mother = fromDisplayString('AA CC DD EE GG PP spsp Rere')
const result = breed(father, mother)
// ReRe survives (semi-lethal, not removed).
expect(result.offspring.some((o) => o.genotype.includes('ReRe'))).toBe(true)
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
expect(sum).toBeCloseTo(1, 10)
const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.RexSemiLethal)
expect(warn).toBeDefined()
})
})
describe('Farbschlag catalog', () => {
it('names the wild type Agouti', () => {
expect(farbschlagFor(wildType()).name).toBe('Agouti')
})
it('names Platin (aa CC DD EE GG pp)', () => {
expect(farbschlagFor(fromDisplayString('aa CC DD EE GG pp spsp rere')).name).toBe('Platin')
})
it('names Schwarz (aa CC DD EE GG PP)', () => {
expect(farbschlagFor(fromDisplayString('aa CC DD EE GG PP spsp rere')).name).toBe('Schwarz')
})
it('appends Schecke and Rex modifiers', () => {
expect(farbschlagFor(fromDisplayString('AA CC DD EE GG PP Spsp rere')).name).toBe('Agouti Schecke')
expect(farbschlagFor(fromDisplayString('AA CC DD EE GG PP Spsp Rere')).name).toBe(
'Agouti Schecke Rex',
)
})
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 CC dd EE gg pp spsp rere'))
expect(match.unknown).toBe(true)
expect(match.name).toBe('Unbekannter Farbschlag')
})
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
expect(CATALOG_SIZE).toBe(70)
})
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
// The first 17 frozen ColorVariety keys — their representative genotype
// MUST resolve back to their own name, never a later variety.
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
// chch+pp → REW engine check → 'REW' = entry.name.
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
}
})
it('every entry resolves to a named variety (no self-Unbekannt)', () => {
// Each catalogued variety's representative genotype must resolve to SOME
// named variety — never "Unbekannt". Genotype-identical synonyms (e.g.
// "-hell") or entries that omit a locus may resolve to an earlier primary;
// that's expected first-match behaviour, not an error.
for (const entry of BASE_COLORS) {
expect(farbschlagFor(representativeGenotype(entry)).unknown).toBe(false)
}
})
it('has no duplicate variety names and only valid allele tokens', () => {
const names = BASE_COLORS.map((e) => e.name)
expect(new Set(names).size).toBe(names.length)
for (const entry of BASE_COLORS) {
for (const [locus, value] of Object.entries(entry.tokens)) {
// GEN-3f: a token may be a heterozygous pair "x/y" (e.g. C: 'cchm/ch'
// for the het colourpoints Siam/Zobel-Hell) — validate each allele.
for (const allele of value.split('/')) {
expect(LOCI[locus as LocusKey].alleles).toContain(allele)
}
}
}
})
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
})
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 CC dd EE gg pp spsp rere'))).toBe(
'Unbekannter Farbschlag',
)
})
})
describe('Partially-unknown parents (wildcards)', () => {
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
// = effectively 3/4 A, 1/4 a ; mother always a.
// Offspring: 3/4 Aa, 1/4 aa.
const father = makeGenotype({
A: ['A', '?'],
C: ['C', 'C'],
D: ['D', 'D'],
E: ['E', 'E'],
G: ['G', 'G'],
P: ['P', 'P'],
Sp: ['sp', 'sp'],
Re: ['re', 're'],
Sls: ['sl', 'sl'],
})
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
const result = breed(father, mother)
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
expect(agouti.probability.text).toBe('3/4')
expect(schwarz.probability.text).toBe('1/4')
})
})
describe('Probabilities are exact fractions summing to 1', () => {
it('Aa Cc x Aa Cc -> 9 genotypes summing to 1', () => {
const father = fromDisplayString('Aa Cch DD EE GG PP spsp rere')
const mother = fromDisplayString('Aa Cch DD EE GG PP spsp rere')
const result = breed(father, mother)
const sum = result.offspring.reduce(
(acc, o) => add(acc, o.probability.fraction),
frac(0, 1),
)
expect(toNumber(sum)).toBeCloseTo(1, 10)
})
})
describe('GEN-3a: Uw=G alias', () => {
it('Uwuw parses as Gg, UwUw as GG', () => {
expect(fromDisplayString('AA CC DD EE Uwuw PP spsp rere').G).toEqual(['G', 'g'])
expect(fromDisplayString('AA CC DD EE UwUw PP spsp rere').G).toEqual(['G', 'G'])
expect(fromDisplayString('AA CC DD EE uwuw PP spsp rere').G).toEqual(['g', 'g'])
})
it('always RENDERS G, never Uw (breeder preference)', () => {
// Uw/uw is an input/import alias only; output must echo G/g.
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
'AA CC DD EE Gg PP spsp',
)
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
})
it('#32/#34: dense-underwhite [d] annotation is stripped (Uwuw[d] → Gg, never uw)', () => {
// Vance's real genotype: "aa Cc[chm] D- ee Uwuw[d] PP spsp" → Kohlfuchs, not Unbekannt.
expect(fromDisplayString('AA CC DD EE Uwuw[d] PP spsp rere').G).toEqual(['G', 'g'])
expect(fromDisplayString('AA CC DD EE uw[d]uw[d] PP spsp rere').G).toEqual(['g', 'g'])
const vance = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
expect(vance.G).toEqual(['G', 'g'])
const display = toDisplayString(vance)
expect(display).toContain('Gg')
expect(display.toLowerCase()).not.toContain('uw')
expect(genotypeToFarbschlag(vance)).toBe('Kohlfuchs')
})
})
describe('#42: E-locus Algierfuchs (ee[-] visible Fuchs)', () => {
it('Aa CC D- ee[-] Gg Pp spsp → Algierfuchs', () => {
expect(genotypeToFarbschlag(fromDisplayString('Aa CC D- ee[-] Gg Pp spsp'))).toBe('Algierfuchs')
})
})
describe('#3: aa colourpoint respects D (dilute) and E (Fuchs)', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('does NOT collapse aa colourpoint with dd/Fuchs to Zobel', () => {
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel')
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel Schecke')
})
it('aa cchm with full D + full extension still resolves to Marder/Zobel (regression)', () => {
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
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')
})
})
describe('#37/#39/#40/#41: wildcard expansion is dominance/visibility constrained', () => {
it('an unknown E partner of a visible E never introduces ef (no phantom Schimmel)', () => {
// Mamta Mini (Ee[-] = [E,?]) × Gold (Ee): no Schimmel/efef/Unbekannt offspring.
const r = breed(
fromDisplayString('AA CC D- Ee[-] Gg PP spsp'),
fromDisplayString('Aa CC D- Ee Gg pp spsp'),
)
const names = r.byFarbschlag.map((f) => f.farbschlag)
expect(names).not.toContain('Unbekannter Farbschlag')
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
expect(r.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
})
it('a fox parent (ee) × Ee never yields impossible non-fox-only morphs from a wildcard', () => {
// Geely (ee gg, with A?/C?/D? wildcards) × Gaida: no Unbekannt, no Schimmel.
const r = breed(
fromDisplayString('A- C- D- ee[-] gg Pp spsp'),
fromDisplayString('A- CC D- Ee GG Pp spsp'),
)
const names = r.byFarbschlag.map((f) => f.farbschlag)
expect(names).not.toContain('Unbekannter Farbschlag')
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
})
})
describe('GEN-3a: second spotting locus Sls (WP)', () => {
it('S(l)s(l) and WP both parse to the Sls heterozygote Slsl', () => {
expect(fromDisplayString('AA CC DD EE GG PP spsp rere S(l)s(l)').Sls).toEqual(['Sl', 'sl'])
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
})
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
// Rex het → Rere shown
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
'AA CC DD EE GG PP spsp Rere',
)
// WP → Slsl shown, rere still omitted
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp Slsl',
)
})
it('WP × WP: S(l)S(l) is lethal — removed, renormalized, SLS_LETHAL warning', () => {
const parent = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
const result = breed(parent, parent)
expect(result.offspring.every((o) => !o.genotype.includes('SlSl'))).toBe(true)
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
expect(sum).toBeCloseTo(1, 10)
// survivors 2/3 Slsl : 1/3 slsl
const wp = result.offspring.find((o) => o.genotype.includes('Slsl'))!
expect(wp.probability.text).toBe('2/3')
const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.SlsLethal)
expect(warn?.detail?.youngLostFraction).toBe('1/4')
})
it('Sp × Sls → Superschecke deafness warning', () => {
const father = fromDisplayString('AA CC DD EE GG PP Spsp rere')
const mother = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
const result = breed(father, mother)
expect(
result.warnings.some((w) => w.code === GeneticsWarningCode.SuperscheckeDeaf),
).toBe(true)
})
})
describe('GEN-3a: flag/metadata tokens tolerated', () => {
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
})
it('extractGenotypeFlags reads deafness + tags', () => {
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere dea WFNZ').deaf).toBe(true)
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere Dea').deaf).toBe(false)
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere WFNZ RV').tags).toEqual(['WFNZ', 'RV'])
})
})
describe('GEN-3a: Schimmel catalog fix (breeder C5)', () => {
it('efef base -> Orangeschimmel (not Schwarzschimmel)', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'Orangeschimmel',
)
})
it('efef pp -> Rotaugenschimmel, efef gg -> Silberschimmel', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG pp spsp rere'))).toBe(
'Rotaugenschimmel',
)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef gg PP spsp rere'))).toBe(
'Silberschimmel',
)
})
it('no Schwarzschimmel anywhere in the catalog', () => {
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') // 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',
)
})
})
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Kohlfuchsschimmel',
)
})
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)
})
})
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 REW/Hermelin/Himalaya.
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
expect(name).toBe('Schwarz')
expect(['REW', '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 -> specific variety, not category (GEN-4 update)', () => {
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Kohlfuchsschimmel',
)
})
})
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--Hell (GEN-3g: het gets -Hell suffix)', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
})
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')
})
})
describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her live data)', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
it('every CP- name her data uses exists as a catalog row (import name-match)', () => {
for (const n of [
'CP-Agouti', 'CP-Silberagouti', 'CP-Algierfuchs', 'CP-Polarfuchs',
'CP-Fuchs', 'CP-Fuchs-Hell', 'CP-Blaufuchs', 'CP-Orangeschimmel',
'Marder', 'Siam', 'Zobel', 'Zobel-Hell',
]) {
expect(has(n)).toBe(true)
}
})
it('the removed portal suffix-names are gone (collapsed)', () => {
for (const n of [
'Agouti CP', 'Agouti CP-Hell', 'Silberagouti CP', 'Algierfuchs CP',
'Polarfuchs CP', 'Blaufuchs CP', 'Kohlfuchs CP', 'Kohlfuchsschimmel CP',
'Marder dd', 'Zobel dd', 'Siam (Marder-Hell)', 'Agouti dd CP',
]) {
expect(has(n)).toBe(false)
}
})
it('the engine computes the agouti CP- names her data uses (E-family fallback covers fox/dilute)', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
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-Algierfuchs')
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
})
})
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
it('all new -Hell het entries exist in catalog', () => {
for (const n of [
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
]) {
expect(has(n)).toBe(true)
}
})
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
})
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('aa non-agouti branch is unchanged (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')
})
})
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('dd entries use Dilute prefix — no more X-dd names', () => {
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
})
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-)
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
// Counterproof: full C present → NOT REW (residual pigment)
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
})
it('Farbarten (categories) never appear as computed results', () => {
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
for (const cat of CATS) {
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
}
})
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
})
})
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
// ── Display symbols ────────────────────────────────────────────────────
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp',
)
// C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp',
)
// C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp',
)
})
// ── E-locus display order: E > e > e[f] ─────────────────────────────
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
// Stored canonical: [ef, e] (ef dominant over e in storage).
// Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
})
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp',
)
})
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp',
)
})
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp'
const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe(display)
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
})
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display)
})
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp'
const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd'])
expect(toDisplayString(g)).toBe(display)
})
// ── Parser accepts both forms ─────────────────────────────────────────
it('bracket input round-trips identically to internal-symbol input', () => {
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
)
})
it('#42: e[-]/e- standalone is INVALID (lone recessive fox + unknown) → throws', () => {
// Fox (e) is recessive; a single e with an unknown partner is genetically
// impossible. The breeder's rule: only "E-" and "ee[-]" exist, never "e-".
expect(() => fromDisplayString('aa CC DD e[-] GG PP spsp rere')).toThrow()
expect(() => fromDisplayString('aa CC DD e- GG PP spsp rere')).toThrow()
})
it('#42: ee[-] resolves to ee (Fuchs) — recessive phenotype implies homozygosity', () => {
// Real herdbook notation: ee[-] = visible fox. Since fox is recessive a
// visible fox MUST be ee, so the unknown second allele resolves to e.
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
const g = fromDisplayString(input)
expect(g.E).toEqual(['e', 'e'])
expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd ee Gg Pp Spsp')
})
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
it('handles blank c locus and c[-] resiliently without crashing', () => {
// Vandana's genotype
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
const g = fromDisplayString(rawVandana)
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
// cchmc allele
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
expect(g2.C).toEqual(['cchm', '?'])
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
// c[-] and blank c standalone
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
})
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
// 1. Parseable with unknown alleles, replacing ? with -
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
// 2. Parseable with trailing ?? (stripped completely)
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
// 4. Handles null/undefined/empty
expect(displayGenotypeSafe(null)).toBe('')
expect(displayGenotypeSafe(undefined)).toBe('')
expect(displayGenotypeSafe('')).toBe('')
})
})