Merge branch 'worktree-agent-a7281df1f2cc3f1f1'
# Conflicts: # gerbil-manager-web/src/genetics/catalog.ts # gerbil-manager-web/src/genetics/genotype.ts # gerbil-manager-web/src/genetics/punnett.ts
This commit is contained in:
@@ -17,6 +17,8 @@ import {
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
displayGenotypeSafe,
|
||||
resolveAllelePair,
|
||||
inferUnknownsFromParents,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
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.
|
||||
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
|
||||
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
|
||||
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
|
||||
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
|
||||
// a recessive 'a' gamete the parent demonstrably does not show.)
|
||||
const father = makeGenotype({
|
||||
A: ['A', '?'],
|
||||
C: ['C', 'C'],
|
||||
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
|
||||
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')
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
expect(result.offspring[0].farbschlag).toBe('Agouti')
|
||||
expect(result.offspring[0].probability.text).toBe('1')
|
||||
expect(result.byFarbschlag.some((f) => f.farbschlag === 'Schwarz')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -603,7 +606,10 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is BLACK-eyed (P), and dilution is
|
||||
// independent of the P-locus, so Dilute Blaufuchs is aa dd ee gg P- (was
|
||||
// wrongly P:'p'). The pink-eyed variant is a different (REW-adjacent) colour.
|
||||
expect(name('aa CC dd ee gg PP spsp rere')).toBe('Dilute Blaufuchs')
|
||||
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||
})
|
||||
@@ -628,10 +634,13 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
|
||||
it('Farbarten (categories) never appear as computed results', () => {
|
||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||
// GEN-5 (ticket 5826e8e2): het ef/e is the FUCHSSCHIMMEL family — it resolves to
|
||||
// a *fuchsschimmel variety, NEVER a pure Schimmel (Rotaugen-/Orangeschimmel).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Agouti ef/e black-eyed → Algierfuchsschimmel (A:A,E:ef,G:G,P:P), NOT Orangeschimmel.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Algierfuchsschimmel')
|
||||
// hom ef/ef agouti black-eyed → the pure Orangeschimmel (Schimmel family).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||
@@ -789,3 +798,118 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
expect(displayGenotypeSafe('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: unknown allele = copy of known (resolveAllelePair, breeder rule)', () => {
|
||||
it('a single-unknown pair resolves to the homozygote of the KNOWN allele', () => {
|
||||
expect(resolveAllelePair('A', ['A', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('A', ['?', 'a'])).toEqual(['a', 'a'])
|
||||
expect(resolveAllelePair('D', ['D', '?'])).toEqual(['D', 'D'])
|
||||
expect(resolveAllelePair('E', ['e', '?'])).toEqual(['e', 'e']) // ee[-] = Fuchs
|
||||
expect(resolveAllelePair('E', ['ef', '?'])).toEqual(['ef', 'ef']) // ef[-] = Schimmel
|
||||
})
|
||||
it('a fully-unknown pair falls back to wild-type (markers stay unmarked)', () => {
|
||||
expect(resolveAllelePair('A', ['?', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('C', ['?', '?'])).toEqual(['C', 'C'])
|
||||
expect(resolveAllelePair('Sp', ['?', '?'])).toEqual(['sp', 'sp']) // never implies Schecke
|
||||
})
|
||||
it('a fully-known pair is returned unchanged', () => {
|
||||
expect(resolveAllelePair('C', ['C', 'ch'])).toEqual(['C', 'ch'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: genetics-engine ticket reproductions (real stored genotypes)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('5826e8e2: ee[f] het (Fuchsschimmel) + pp → Goldfuchsschimmel, NOT Rotaugenschimmel', () => {
|
||||
// Tier bf6f4507: Aa C- D- ee[f] G- pp Spsp — het ef/e is a Fuchsschimmel.
|
||||
expect(name('Aa C- D- ee[f] G- pp Spsp')).toBe('Goldfuchsschimmel Schecke')
|
||||
// and the pure hom ef/ef pp stays the pure Schimmel:
|
||||
expect(name('AA CC DD efef GG pp spsp')).toBe('Rotaugenschimmel')
|
||||
})
|
||||
|
||||
it('b034ddd2: ee[-] (e + unknown) → ee Fuchs → Algierfuchs, NOT Agouti', () => {
|
||||
// Tier 33a7c1f9: Aa CC D- ee[-] Gg Pp spsp. Old engine read [e,?] as e/E → Agouti.
|
||||
expect(name('Aa CC D- ee[-] Gg Pp spsp')).toBe('Algierfuchs')
|
||||
})
|
||||
|
||||
it('3deab547 / efa2b232: aa cchm dd ee[-] gg P- → Dilute CP-Blaufuchs, NOT Zobel/blau/Unbekannt', () => {
|
||||
// Tier 6864eaef. Non-agouti Fuchs colourpoint is NOT a marten (Zobel) — it
|
||||
// derives a CP-fox base with the Dilute prefix.
|
||||
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).toBe('Dilute CP-Blaufuchs Schecke')
|
||||
})
|
||||
|
||||
it('473dc345 / 5151ab20: Vance uw[d] (dense underwhite) parses (no crash) → Kohlfuchs', () => {
|
||||
// Tier f31eb1f9: aa Cc[chm] D- ee Uwuw[d] PP spsp. uw[d] is the G locus;
|
||||
// it must parse and NEVER render 'uw'.
|
||||
const g = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
|
||||
expect(g.G).toEqual(['G', 'g'])
|
||||
expect(toDisplayString(g)).not.toContain('uw')
|
||||
expect(genotypeToFarbschlag(g)).toBe('Kohlfuchs')
|
||||
})
|
||||
|
||||
it('Fuchsschimmel family never resolves to a pure Schimmel variety', () => {
|
||||
// Agouti het ef/e black-eyed → Algierfuchsschimmel; hom ef/ef → Orangeschimmel.
|
||||
expect(name('AA CC DD eef GG PP spsp')).toBe('Algierfuchsschimmel')
|
||||
expect(name('AA CC DD efef GG PP spsp')).toBe('Orangeschimmel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: no phantom colours in the expected-litter list (3e643ef1/c8ce27e2/3c46d0b4/1e7b66e6)', () => {
|
||||
it('Mamta Mini (D-, Ee[-]) × Gold (D-, Ee): unknown D copies known D → no Dilute, no Unbekannt, no efef', () => {
|
||||
// Real litter 98bfdf92. Both parents carry D- (unknown D) and an unknown E
|
||||
// partner. The old uniform-spread invented dd / ef / 'Unbekannt' offspring.
|
||||
const father = fromDisplayString('AA CC D- Ee[-] Gg PP spsp') // Mamta Mini
|
||||
const mother = fromDisplayString('Aa CC D- Ee Gg pp spsp') // Gold
|
||||
const result = breed(father, mother)
|
||||
|
||||
const names = result.byFarbschlag.map((f) => f.farbschlag)
|
||||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||
expect(names.some((n) => n.startsWith('Dilute'))).toBe(false)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
|
||||
// Probabilities still sum to exactly 1.
|
||||
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
|
||||
expect(sum).toBeCloseTo(1, 10)
|
||||
// Only agouti vs silver-agouti can fall here (G locus segregates; everything else fixed).
|
||||
expect(new Set(names)).toEqual(new Set(['Agouti', 'Silberagouti']))
|
||||
})
|
||||
|
||||
it('D- × D- never yields a dd (dilute) offspring at all', () => {
|
||||
const p = fromDisplayString('AA CC D- EE GG PP spsp')
|
||||
const result = breed(p, p)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('dd'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)', () => {
|
||||
it('Mamta Mini Ee[-] + homozygous ee father Geely → Ee', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] Gg PP spsp')
|
||||
const geely = fromDisplayString('aa CC DD ee gg PP spsp') // father: ee (hom fox)
|
||||
const res = inferUnknownsFromParents(child, geely, null)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(res.genotype)).toBe('AA CC DD Ee Gg PP spsp')
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'father' }])
|
||||
})
|
||||
|
||||
it('falls back to the mother when only she is homozygous', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp') // het → no force
|
||||
const mother = fromDisplayString('aa CC DD ee GG PP spsp') // ee → forces e
|
||||
const res = inferUnknownsFromParents(child, father, mother)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'mother' }])
|
||||
})
|
||||
|
||||
it('leaves the genotype untouched when no parent is homozygous at the unknown locus', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, father, null)
|
||||
expect(res.inferred).toEqual([])
|
||||
expect(res.genotype.E).toEqual(child.E)
|
||||
})
|
||||
|
||||
it('no-op when there is nothing unknown', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, child, child)
|
||||
expect(res.inferred).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user