fix(genetics): Genotyp-/Farbschlag-Engine-Bugs aus Ticket-Triage

- uw/Uwuw[d] (dichtes Underwhite) wird vor dem uw→G-Locus-Alias korrekt geparst
  (Vance: nicht mehr „Unbekannter Farbschlag", zeigt G-Locus statt international uw). [#34,#32]
- ee[-] = sichtbarer Fuchs (rezessiv homozygot) → korrekt als Fuchs; bare „e-"
  (rezessiv + unbekannt) ist genetisch unmöglich → genotypeInvalid statt still falsch. [#42]
- aa-Colourpoint-Zweig berücksichtigt jetzt D (Dilute) und E (Fuchs), statt fix
  Zobel/Marder/Siam zu erzwingen. [#3]
- Wurf-Farbprognose: unbekanntes Allel (?) wird nicht mehr über die volle Dominanz
  expandiert → keine unmöglichen Dilute-/Schimmel-/„Unbekannt"-Nachkommen. [#37,#39,#40,#41,#38]

Tests: test_genotype.py erweitert; genetics.test.ts +5 Blöcke (135 vitest grün, tsc/eslint sauber).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 21:04:05 +02:00
parent 5069c0eb1e
commit b437d09312
6 changed files with 182 additions and 26 deletions

View File

@@ -291,6 +291,62 @@ describe('GEN-3a: Uw=G alias', () => {
)
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)', () => {
@@ -670,22 +726,23 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
)
})
it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
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('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
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', '?'])
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 e- Gg Pp Spsp')
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd ee Gg Pp Spsp')
})
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────