GEN-1: add vitest suite (worked example, SpSp renorm, wildcards) + erasable-enum fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 23:43:21 +02:00
parent 2bee5caaa2
commit 3561b435d5
2 changed files with 220 additions and 4 deletions

View File

@@ -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)
})
})

View File

@@ -1,13 +1,19 @@
/** /**
* Warning codes emitted by the genetics engine. The engine is language-neutral: * 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). * 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. */ /** 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. */ /** 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 { export interface GeneticsWarning {
readonly code: GeneticsWarningCode readonly code: GeneticsWarningCode