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:
210
gerbil-manager-web/src/genetics/__tests__/genetics.test.ts
Normal file
210
gerbil-manager-web/src/genetics/__tests__/genetics.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user