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- (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 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') }) })