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 cchmcchm dd EE gg pp spsp rere')) expect(match.unknown).toBe(true) expect(match.name).toBe('Unbekannter Farbschlag') }) it('has the expected catalogue coverage (GEN-2: frozen 18 + 55 portal varieties)', () => { expect(CATALOG_SIZE).toBe(73) }) 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. 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)) { expect(LOCI[locus as LocusKey].alleles).toContain(value) } } }) 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. 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 cchmcchm 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) }) })