- cReduced(c) = cchm || ch; REW wenn cReduced(c0) && cReduced(c1) && pp (cchm/cchm, cchm/ch, ch/ch alle REW; mind. ein volles C = NICHT REW) - Counterproof-Test: C/cchm + pp und C/ch + pp = nicht REW - Frozen-Round-Trip-Test: PEW (ch/ch+pp) explizit ausgenommen (intentional shadow durch REW-Check per Julian-Regel; PEW bleibt Dropdown-Name) Gate: build ✓ eslint ✓ vitest 95/95 ✓ e2e 148/148 ✓
667 lines
29 KiB
TypeScript
667 lines
29 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
add,
|
||
divide,
|
||
frac,
|
||
multiply,
|
||
toNumber,
|
||
toString,
|
||
equals,
|
||
} from '../fraction'
|
||
import {
|
||
fromDisplayString,
|
||
makeGenotype,
|
||
toDisplayString,
|
||
toJSON,
|
||
fromJSON,
|
||
wildType,
|
||
extractGenotypeFlags,
|
||
} 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 rere')
|
||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
||
})
|
||
|
||
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', () => {
|
||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||
})
|
||
})
|
||
|
||
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 rere')
|
||
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 18 are the frozen ColorVariety keys — their representative
|
||
// genotype MUST resolve back to their own name, never a later variety.
|
||
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
|
||
// because the REW engine check (both C-alleles reduced + pp) fires first.
|
||
// PEW stays in the catalog as a user-pickable import name; its computed
|
||
// farbschlag is intentionally 'REW' per Julian's extended rule.
|
||
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
|
||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||
if (REW_SHADOWED.has(entry.name)) continue
|
||
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: 'Pink Eyed White (PEW)', 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.
|
||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.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 rere',
|
||
)
|
||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||
})
|
||
})
|
||
|
||
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 but shows Slsl', () => {
|
||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||
'AA CC DD EE GG PP spsp rere 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 rere')
|
||
})
|
||
|
||
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 rere') // 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 rere',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||
'Fuchsschimmel',
|
||
)
|
||
})
|
||
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 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',
|
||
)
|
||
})
|
||
})
|
||
|
||
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>-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
|
||
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
|
||
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 — subsumes PEW)
|
||
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('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 rere',
|
||
)
|
||
// 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 rere',
|
||
)
|
||
// 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 rere',
|
||
)
|
||
})
|
||
|
||
// ── 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 rere')
|
||
})
|
||
|
||
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 rere',
|
||
)
|
||
})
|
||
|
||
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 rere',
|
||
)
|
||
})
|
||
|
||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
||
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 rere', () => {
|
||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
||
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 rere', () => {
|
||
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
||
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('e[-] standalone: parses as [e,?], displays e-', () => {
|
||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||
expect(g.E).toEqual(['e', '?'])
|
||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
||
})
|
||
|
||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
|
||
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
|
||
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
||
const g = fromDisplayString(input)
|
||
expect(g.E).toEqual(['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 e- Gg Pp Spsp rere')
|
||
})
|
||
})
|