Genetik-Engine (bde4ec70, f89e95ad, 2322c2a8):
- Saphir/Platin/Platin-Hell unterscheiden sich NUR in der C-Zygotie und waren im
Katalog identisch (locusToken reduziert auf das dominante Allel) → Saphir war
unerreichbar. Token darf jetzt ein exaktes, ungeordnetes Allelpaar "x/y"
verlangen: Platin C/C, Saphir C/cchm, Platin-Hell C/ch. TS- und Python-Mirror
identisch, Seeds regeneriert, Migration ReseedColorVarietiesGen6Saphir.
- c[hm] wird als Alias auf c[chm] normalisiert (Tippfehler in zwei Charts) —
vorher war Jays Gencode unparsebar und ergab "Zobel-Hell".
- 6 Tiere wechseln den errechneten Farbschlag (5x Platin→Saphir, Jay→Zobel).
Importer-Logik:
- renameTo-Notiz-Sweep: der alte Name blieb in Wurf-Notizen stehen ("Blacky +
Kruke") — jetzt wortgenau ersetzt, mit vier Guards gegen Kollateralschaden.
- Neuer Resolution-Schlüssel goHomeDate (autoritativ, zieht GivenAway nach) und
spottingType; Gencode-Overrides gewinnen jetzt bei präzisem Match (vorher
wurden Entscheidungen stumm verschluckt, z. B. Eliza und Chris).
- parse_date verwirft implausible Jahre (JackJack hatte 1310-05-13).
- extract.py erkennt Stammbaum-Blöcke auch ohne Stern vor dem Geburtsdatum
(Bijou bekam dadurch den Nachbar-Ast als Mutter).
- Backend-Ingest überträgt SpottingType (nur bei Payload-Wert).
Daten (conflict-decisions.json, re-ingest-stabil): Kuke-Merge + Zuchtname,
Jamie- und Sakura-Dubletten, Phantom-Tier "Unbekannt", Eltern von Kathlin,
Fast Boy/Ziwa und dem Q4-/TS-Wurf, Eliza-Gencode, Merle-Abgabe, JackJack.
Frontend (24522f5f): Abgabedatum steht in der Tier-Akte jetzt direkt unter dem
Abnehmer und hängt nicht mehr am Status.
Tests: +25 Checks test_merge_resolve, +7 test_genotype, +8 genetics.test.ts,
neuer Extract- und Ingest-Test, e2e GOHOME-ROW. Alles grün.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
981 lines
45 KiB
TypeScript
981 lines
45 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
add,
|
||
divide,
|
||
frac,
|
||
multiply,
|
||
toNumber,
|
||
toString,
|
||
equals,
|
||
} from '../fraction'
|
||
import {
|
||
fromDisplayString,
|
||
makeGenotype,
|
||
toDisplayString,
|
||
toJSON,
|
||
fromJSON,
|
||
wildType,
|
||
extractGenotypeFlags,
|
||
displayGenotypeSafe,
|
||
resolveAllelePair,
|
||
inferUnknownsFromParents,
|
||
} 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')
|
||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
|
||
})
|
||
|
||
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 omitted)', () => {
|
||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||
})
|
||
})
|
||
|
||
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')
|
||
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 CC dd EE gg pp spsp rere'))
|
||
expect(match.unknown).toBe(true)
|
||
expect(match.name).toBe('Unbekannter Farbschlag')
|
||
})
|
||
|
||
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
|
||
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
|
||
expect(CATALOG_SIZE).toBe(70)
|
||
})
|
||
|
||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||
// The first 17 frozen ColorVariety keys — their representative genotype
|
||
// MUST resolve back to their own name, never a later variety.
|
||
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
|
||
// chch+pp → REW engine check → 'REW' = entry.name.
|
||
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)) {
|
||
// GEN-3f: a token may be a heterozygous pair "x/y" (e.g. C: 'cchm/ch'
|
||
// for the het colourpoints Siam/Zobel-Hell) — validate each allele.
|
||
for (const allele of value.split('/')) {
|
||
expect(LOCI[locus as LocusKey].alleles).toContain(allele)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
|
||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
||
// Every row has a non-empty canonical genotype display string and unique name.
|
||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
|
||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.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 CC dd EE gg pp spsp rere'))).toBe(
|
||
'Unbekannter Farbschlag',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('Partially-unknown parents (wildcards)', () => {
|
||
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'],
|
||
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)
|
||
|
||
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)
|
||
})
|
||
})
|
||
|
||
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',
|
||
)
|
||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||
})
|
||
|
||
it('#32/#34: dense-underwhite [d] annotation is stripped (Uwuw[d] → Gg, never uw)', () => {
|
||
// Vance's real genotype: "aa Cc[chm] D- ee Uwuw[d] PP spsp" → Kohlfuchs, not Unbekannt.
|
||
expect(fromDisplayString('AA CC DD EE Uwuw[d] PP spsp rere').G).toEqual(['G', 'g'])
|
||
expect(fromDisplayString('AA CC DD EE uw[d]uw[d] PP spsp rere').G).toEqual(['g', 'g'])
|
||
const vance = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
|
||
expect(vance.G).toEqual(['G', 'g'])
|
||
const display = toDisplayString(vance)
|
||
expect(display).toContain('Gg')
|
||
expect(display.toLowerCase()).not.toContain('uw')
|
||
expect(genotypeToFarbschlag(vance)).toBe('Kohlfuchs')
|
||
})
|
||
})
|
||
|
||
describe('#42: E-locus Algierfuchs (ee[-] visible Fuchs)', () => {
|
||
it('Aa CC D- ee[-] Gg Pp spsp → Algierfuchs', () => {
|
||
expect(genotypeToFarbschlag(fromDisplayString('Aa CC D- ee[-] Gg Pp spsp'))).toBe('Algierfuchs')
|
||
})
|
||
})
|
||
|
||
describe('#3: aa colourpoint respects D (dilute) and E (Fuchs)', () => {
|
||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||
it('does NOT collapse aa colourpoint with dd/Fuchs to Zobel', () => {
|
||
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel')
|
||
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).not.toBe('Zobel Schecke')
|
||
})
|
||
it('aa cchm with full D + full extension still resolves to Marder/Zobel (regression)', () => {
|
||
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||
})
|
||
})
|
||
|
||
describe('#37/#39/#40/#41: wildcard expansion is dominance/visibility constrained', () => {
|
||
it('an unknown E partner of a visible E never introduces ef (no phantom Schimmel)', () => {
|
||
// Mamta Mini (Ee[-] = [E,?]) × Gold (Ee): no Schimmel/efef/Unbekannt offspring.
|
||
const r = breed(
|
||
fromDisplayString('AA CC D- Ee[-] Gg PP spsp'),
|
||
fromDisplayString('Aa CC D- Ee Gg pp spsp'),
|
||
)
|
||
const names = r.byFarbschlag.map((f) => f.farbschlag)
|
||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
|
||
expect(r.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
|
||
})
|
||
|
||
it('a fox parent (ee) × Ee never yields impossible non-fox-only morphs from a wildcard', () => {
|
||
// Geely (ee gg, with A?/C?/D? wildcards) × Gaida: no Unbekannt, no Schimmel.
|
||
const r = breed(
|
||
fromDisplayString('A- C- D- ee[-] gg Pp spsp'),
|
||
fromDisplayString('A- CC D- Ee GG Pp spsp'),
|
||
)
|
||
const names = r.byFarbschlag.map((f) => f.farbschlag)
|
||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||
expect(names.some((n) => /schimmel/i.test(n))).toBe(false)
|
||
})
|
||
})
|
||
|
||
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 and Re, shows Slsl/Rere when non-wildtype', () => {
|
||
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
|
||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||
// Rex het → Rere shown
|
||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
|
||
'AA CC DD EE GG PP spsp Rere',
|
||
)
|
||
// WP → Slsl shown, rere still omitted
|
||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||
'AA CC DD EE GG PP spsp 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')
|
||
})
|
||
|
||
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') // 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',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
|
||
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
|
||
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
|
||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||
'Kohlfuchsschimmel',
|
||
)
|
||
})
|
||
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)
|
||
})
|
||
})
|
||
|
||
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
|
||
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
||
expect(name).toBe('Schwarz')
|
||
expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||
})
|
||
|
||
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
||
// 'sp-' (one sp + unknown) -> sp/sp -> no Schecke (naive most-dominant would wrongly add it).
|
||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
||
})
|
||
|
||
it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
|
||
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
|
||
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
|
||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||
'Kohlfuchsschimmel',
|
||
)
|
||
})
|
||
})
|
||
|
||
describe('GEN-3e: C-locus colourpoint naming', () => {
|
||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||
|
||
it('A- + cchm/cchm -> CP-<base colour> (CP-Silberagouti example)', () => {
|
||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||
})
|
||
|
||
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
|
||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
|
||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||
})
|
||
|
||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||
})
|
||
|
||
it('chch stays Hermelin (aa) / Himalaya (A-); full C => no CP', () => {
|
||
expect(name('aa chch DD EE GG PP spsp rere')).toBe('Hermelin')
|
||
expect(name('AA chch DD EE GG PP spsp rere')).toBe('Himalaya')
|
||
expect(name('AA CC DD EE GG PP spsp rere')).toBe('Agouti') // one full C -> no colourpoint
|
||
})
|
||
|
||
it('colourpoint composes with the Schecke modifier', () => {
|
||
expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke')
|
||
})
|
||
})
|
||
|
||
describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her live data)', () => {
|
||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||
|
||
it('every CP- name her data uses exists as a catalog row (import name-match)', () => {
|
||
for (const n of [
|
||
'CP-Agouti', 'CP-Silberagouti', 'CP-Algierfuchs', 'CP-Polarfuchs',
|
||
'CP-Fuchs', 'CP-Fuchs-Hell', 'CP-Blaufuchs', 'CP-Orangeschimmel',
|
||
'Marder', 'Siam', 'Zobel', 'Zobel-Hell',
|
||
]) {
|
||
expect(has(n)).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('the removed portal suffix-names are gone (collapsed)', () => {
|
||
for (const n of [
|
||
'Agouti CP', 'Agouti CP-Hell', 'Silberagouti CP', 'Algierfuchs CP',
|
||
'Polarfuchs CP', 'Blaufuchs CP', 'Kohlfuchs CP', 'Kohlfuchsschimmel CP',
|
||
'Marder dd', 'Zobel dd', 'Siam (Marder-Hell)', 'Agouti dd CP',
|
||
]) {
|
||
expect(has(n)).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('the engine computes the agouti CP- names her data uses (E-family fallback covers fox/dilute)', () => {
|
||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
|
||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
|
||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||
})
|
||
|
||
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
|
||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
|
||
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
|
||
})
|
||
})
|
||
|
||
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
|
||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||
|
||
it('all new -Hell het entries exist in catalog', () => {
|
||
for (const n of [
|
||
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
|
||
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
|
||
]) {
|
||
expect(has(n)).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
|
||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
|
||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||
})
|
||
|
||
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
|
||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||
})
|
||
|
||
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||
})
|
||
})
|
||
|
||
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||
|
||
it('dd entries use Dilute prefix — no more X-dd names', () => {
|
||
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')
|
||
// 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')
|
||
})
|
||
|
||
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
||
// hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
|
||
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-)
|
||
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
|
||
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
||
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
||
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
||
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
||
// het cchm/ch + pp (Julian: also REW)
|
||
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
||
// ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
|
||
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
||
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
||
// Counterproof: full C present → NOT REW (residual pigment)
|
||
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
|
||
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
|
||
})
|
||
|
||
it('Farbarten (categories) never appear as computed results', () => {
|
||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||
// 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 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)
|
||
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
|
||
for (const cat of CATS) {
|
||
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
|
||
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
|
||
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
|
||
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
|
||
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
|
||
})
|
||
})
|
||
|
||
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
|
||
// ── Display symbols ────────────────────────────────────────────────────
|
||
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||
// Fuchsschimmel: E=[ef,ef] hom
|
||
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||
'AA CC DD e[f]e[f] GG PP spsp',
|
||
)
|
||
// C-locus het: cchm + ch
|
||
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||
'aa c[chm]c[h] DD EE GG PP spsp',
|
||
)
|
||
// C-locus hom cchm
|
||
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||
'aa c[chm]c[chm] DD EE GG PP spsp',
|
||
)
|
||
})
|
||
|
||
// ── E-locus display order: E > e > e[f] ─────────────────────────────
|
||
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
|
||
// Stored canonical: [ef, e] (ef dominant over e in storage).
|
||
// Display must swap to [e, ef] per breeder convention.
|
||
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
|
||
})
|
||
|
||
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||
'aa CC DD Ee GG PP spsp',
|
||
)
|
||
})
|
||
|
||
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||
'aa CC DD Ee[f] GG PP spsp',
|
||
)
|
||
})
|
||
|
||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
|
||
const display = 'aa C- D- ee[f] Gg Pp spsp'
|
||
const g = fromDisplayString(display)
|
||
expect(g.E).toEqual(['ef', 'e'])
|
||
expect(g.C).toEqual(['C', '?'])
|
||
expect(toDisplayString(g)).toBe(display)
|
||
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||
})
|
||
|
||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
|
||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
|
||
const g = fromDisplayString(display)
|
||
expect(g.C).toEqual(['cchm', 'ch'])
|
||
expect(g.E).toEqual(['E', 'e'])
|
||
expect(toDisplayString(g)).toBe(display)
|
||
})
|
||
|
||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
|
||
const display = 'aa Cc[h] dd EE Gg P- Spsp'
|
||
const g = fromDisplayString(display)
|
||
expect(g.C).toEqual(['C', 'ch'])
|
||
expect(g.D).toEqual(['d', 'd'])
|
||
expect(toDisplayString(g)).toBe(display)
|
||
})
|
||
|
||
// ── Parser accepts both forms ─────────────────────────────────────────
|
||
it('bracket input round-trips identically to internal-symbol input', () => {
|
||
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
|
||
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
|
||
)
|
||
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
|
||
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
|
||
)
|
||
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
|
||
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
|
||
)
|
||
})
|
||
|
||
it('#42: e[-]/e- standalone is INVALID (lone recessive fox + unknown) → throws', () => {
|
||
// Fox (e) is recessive; a single e with an unknown partner is genetically
|
||
// impossible. The breeder's rule: only "E-" and "ee[-]" exist, never "e-".
|
||
expect(() => fromDisplayString('aa CC DD e[-] GG PP spsp rere')).toThrow()
|
||
expect(() => fromDisplayString('aa CC DD e- GG PP spsp rere')).toThrow()
|
||
})
|
||
|
||
it('#42: ee[-] resolves to ee (Fuchs) — recessive phenotype implies homozygosity', () => {
|
||
// Real herdbook notation: ee[-] = visible fox. Since fox is recessive a
|
||
// visible fox MUST be ee, so the unknown second allele resolves to e.
|
||
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
||
const g = fromDisplayString(input)
|
||
expect(g.E).toEqual(['e', 'e'])
|
||
expect(g.C).toEqual(['cchm', 'cchm'])
|
||
expect(g.D).toEqual(['D', 'd'])
|
||
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd ee Gg Pp Spsp')
|
||
})
|
||
|
||
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
|
||
it('handles blank c locus and c[-] resiliently without crashing', () => {
|
||
// Vandana's genotype
|
||
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
|
||
const g = fromDisplayString(rawVandana)
|
||
expect(g.C).toEqual(['C', '?'])
|
||
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
|
||
|
||
// cchmc allele
|
||
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
|
||
expect(g2.C).toEqual(['cchm', '?'])
|
||
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
|
||
|
||
// c[-] and blank c standalone
|
||
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
|
||
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
|
||
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
|
||
|
||
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
|
||
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
|
||
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
|
||
})
|
||
|
||
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
|
||
// 1. Parseable with unknown alleles, replacing ? with -
|
||
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
|
||
|
||
// 2. Parseable with trailing ?? (stripped completely)
|
||
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
|
||
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
|
||
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
|
||
|
||
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
|
||
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
|
||
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
|
||
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
|
||
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
|
||
|
||
// 4. Handles null/undefined/empty
|
||
expect(displayGenotypeSafe(null)).toBe('')
|
||
expect(displayGenotypeSafe(undefined)).toBe('')
|
||
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([])
|
||
})
|
||
})
|
||
|
||
describe('GEN-6: C-Locus-Zygotie Platin / Saphir / Platin-Hell (bde4ec70 / f89e95ad)', () => {
|
||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||
|
||
// Züchterin autoritativ (Ticket f89e95ad): „Saphir ist aa Cc[chm] D- E- G- pp und
|
||
// Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!" Vorher trugen Platin,
|
||
// Saphir und Platin-Hell identische Katalog-Tokens (C:'C') → first-match-wins
|
||
// lieferte immer Platin.
|
||
it('aa CC DD EE GG pp -> Platin (zwei volle C)', () => {
|
||
expect(name('aa CC DD EE GG pp spsp rere')).toBe('Platin')
|
||
expect(name('aa CC DD EE GG pp Spsp')).toBe('Platin Schecke')
|
||
})
|
||
|
||
it('aa Cc[chm] DD EE GG pp -> Saphir (ein c[chm] daneben)', () => {
|
||
expect(name('aa Cc[chm] DD EE GG pp spsp rere')).toBe('Saphir')
|
||
})
|
||
|
||
it('JackJack (b6d8b3ef): aa Cc[chm] DD E- G- pp Spsp -> Saphir Schecke, NICHT Platin Schecke', () => {
|
||
expect(name('aa Cc[chm] DD E- G- pp Spsp')).toBe('Saphir Schecke')
|
||
})
|
||
|
||
it('aa Cc[h] DD EE GG pp -> Platin-Hell (GEN-3g: „-Hell" == c[h]-Allel)', () => {
|
||
expect(name('aa Cc[h] DD EE GG pp spsp rere')).toBe('Platin-Hell')
|
||
})
|
||
|
||
it('die drei Katalog-Einträge bilden ihren eigenen Namen zurück (kein Synonym-Kollaps)', () => {
|
||
for (const varietyName of ['Platin', 'Saphir', 'Platin-Hell']) {
|
||
const entry = BASE_COLORS.find((e) => e.name === varietyName)!
|
||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(varietyName)
|
||
}
|
||
})
|
||
|
||
it('Seed-Kompaktnotation „aa Ccchm …" (so schreibt der Import JackSack) -> Saphir Schecke', () => {
|
||
// merge_and_resolve übernimmt bei Farb-LABEL „Saphir-Sp" ohne Gencode den
|
||
// CanonicalGenotype aus colorVarietySeed.backend.json (frozen symbols).
|
||
// Seit GEN-6 ist das die HET-Form — vorher „aa CC …" (= Platin-Gencode).
|
||
expect(name('aa Ccchm DD EE GG pp Spsp rere')).toBe('Saphir Schecke')
|
||
expect(name('aa Cch DD EE GG pp spsp rere')).toBe('Platin-Hell')
|
||
})
|
||
|
||
it('A-Wurf 18.02.2010 (Blacky × Kuke) erwartet Saphir UND Saphir Schecke', () => {
|
||
const blacky = fromDisplayString('aa Cc[chm] DD EE GG Pp spsp') // Vater
|
||
const kuke = fromDisplayString('aa CC DD Ee gg Pp Spsp') // Mutter (rpro3: Anthrazit-Schecke)
|
||
const names = breed(blacky, kuke).byFarbschlag.map((f) => f.farbschlag)
|
||
expect(names).toContain('Saphir')
|
||
expect(names).toContain('Saphir Schecke')
|
||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||
})
|
||
})
|
||
|
||
describe('Schreibfehler-Alias c[hm] == c[chm] (Ticket 2322c2a8, Jay *24.10.2021)', () => {
|
||
it('c[hm] parst als c[chm] und ergibt Zobel, nicht Zobel-Hell', () => {
|
||
// Die Quell-Charts (Alberto Kids / Pukas Kids) schreiben „aa c[chm]c[hm] …" —
|
||
// dem zweiten Allel fehlt das c. Ohne Alias war der Genotyp unparsebar bzw.
|
||
// (im Python-Mirror) ein fremdes Allel → „Zobel-Hell".
|
||
const g = fromDisplayString('aa c[chm]c[hm] Dd Ee gg P- spsp')
|
||
expect(g.C).toEqual(['cchm', 'cchm'])
|
||
expect(genotypeToFarbschlag(g)).toBe('Zobel')
|
||
})
|
||
|
||
it('Cc[hm] bleibt ein c[chm]-Träger (relevant für die Probeverpaarung)', () => {
|
||
expect(fromDisplayString('aa Cc[hm] DD EE GG pp spsp').C).toEqual(['C', 'cchm'])
|
||
expect(genotypeToFarbschlag(fromDisplayString('aa Cc[hm] DD EE GG pp spsp'))).toBe('Saphir')
|
||
})
|
||
})
|