diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts new file mode 100644 index 0000000..076db0e --- /dev/null +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { + add, + divide, + frac, + multiply, + toNumber, + toString, + equals, +} from '../fraction' +import { + fromDisplayString, + makeGenotype, + toDisplayString, + toJSON, + fromJSON, + wildType, +} from '../genotype' +import { combineLocus } from '../punnett' +import { farbschlagFor, CATALOG_SIZE } from '../catalog' +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'], + }) + 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', () => { + // Colourpoint marked + dilute + grey combo not in the catalog. + 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', () => { + expect(CATALOG_SIZE).toBe(18) + }) +}) + +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'], + }) + 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) + }) +}) diff --git a/gerbil-manager-web/src/genetics/warnings.ts b/gerbil-manager-web/src/genetics/warnings.ts index 4c685c9..0f35238 100644 --- a/gerbil-manager-web/src/genetics/warnings.ts +++ b/gerbil-manager-web/src/genetics/warnings.ts @@ -1,13 +1,19 @@ /** * Warning codes emitted by the genetics engine. The engine is language-neutral: * it emits CODES only. German texts are mapped in de.ts (UI layer). + * + * Modelled as a const object (not a TS `enum`) because the project's tsconfig + * enables `erasableSyntaxOnly`. */ -export enum GeneticsWarningCode { +export const GeneticsWarningCode = { /** Schecke × Schecke: SpSp embryos die in utero; ~25% fewer live young. */ - ScheckeLethal = 'SCHECKE_LETHAL', + ScheckeLethal: 'SCHECKE_LETHAL', /** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */ - RexSemiLethal = 'REX_SEMI_LETHAL', -} + RexSemiLethal: 'REX_SEMI_LETHAL', +} as const + +export type GeneticsWarningCode = + (typeof GeneticsWarningCode)[keyof typeof GeneticsWarningCode] export interface GeneticsWarning { readonly code: GeneticsWarningCode