Files
GerbilManager/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts

382 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
})