fix(genetics): Engine-Fixes Ticket-Triage (Goldfuchsschimmel, Dilute CP-Blaufuchs, Eltern-Inferenz, Punnett, uw[d])

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:05:35 +02:00
parent 5e14124322
commit 8c1ee75b81
15 changed files with 2126 additions and 87 deletions

View File

@@ -346,25 +346,28 @@ public class ApplicationContext : DbContext
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
// --- Hell variants (IDs 38-48) ---
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
// --- Dilute (dd) renamed variants (IDs 49-50) ---
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
// --- Marder / Siam / CP- series (IDs 51-66) ---
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class Gen5FuchsschimmelHetSeedFix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "CanonicalGenotype",
value: "AA CC DD efe gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "CanonicalGenotype",
value: "AA CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "CanonicalGenotype",
value: "aa CC DD efe gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "CanonicalGenotype",
value: "AA CC DD efe GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "CanonicalGenotype",
value: "aa CC DD efe GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
column: "CanonicalGenotype",
value: "aa CC dd ee gg PP spsp rere");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "CanonicalGenotype",
value: "AA CC DD efef gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "CanonicalGenotype",
value: "AA CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "CanonicalGenotype",
value: "aa CC DD efef gg PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "CanonicalGenotype",
value: "AA CC DD efef GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG pp spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "CanonicalGenotype",
value: "aa CC DD efef GG PP spsp rere");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
column: "CanonicalGenotype",
value: "aa CC dd ee gg pp spsp rere");
}
}
}

View File

@@ -451,28 +451,28 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
Name = "Polarfuchsschimmel",
SortOrder = 37
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
Name = "Algierfuchsschimmel",
SortOrder = 38
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
Name = "Kohlfuchsschimmel",
SortOrder = 39
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
Name = "Blaufuchsschimmel",
SortOrder = 40
},
@@ -493,7 +493,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
Name = "Goldfuchsschimmel",
SortOrder = 43
},
@@ -514,7 +514,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
Name = "Rotfuchsschimmel",
SortOrder = 46
},
@@ -528,7 +528,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell",
SortOrder = 48
},
@@ -563,7 +563,7 @@ namespace GerbilManagerWebAPI.Migrations
new
{
Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
Name = "Dilute Blaufuchs",
SortOrder = 53
},

View File

@@ -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)
})
})
@@ -547,7 +550,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')
})
@@ -572,10 +578,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)
@@ -732,3 +741,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([])
})
})

View File

@@ -20,12 +20,12 @@
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
*/
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import {
makeGenotype,
resolveAllelePair,
toDisplayString,
wildType,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
@@ -113,7 +113,11 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed (P, line 75); dilution dd is
// independent of the eye-pigment P-locus, so the dilute form is ALSO P:'P'
// (was P:'p', which made it an unreachable phantom and left dd CP-fox animals
// 'Unbekannt'/'blau'). Now aa cchm dd ee gg P- → 'Dilute CP-Blaufuchs'.
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
* variety specifically.
*/
function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
// back to the wild-type reading (most-dominant colour / unmarked marker).
const [x, y] = resolveAllelePair(locus, g[locus])
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
@@ -195,29 +196,50 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
* computed farbschlag output (the farbschlagFor category guard blocks them).
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
// GEN-5: resolve unknown E as a copy of the known allele first ([e,?]→ee Fuchs,
// [ef,?]→ef/ef Schimmel, [E,?]→EE full), so families are decided consistently.
const [x, y] = resolveAllelePair('E', g.E)
if (x === 'e' && y === 'e') return 'Fuchs'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
if (x === 'ef' && y === 'ef') return 'Schimmel'
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
return null
}
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
/**
* Resolve an allele pair to concrete alleles. GEN-5: an unknown allele copies the
* known partner (resolveAllelePair); a fully-unknown locus falls back to wild-type.
*/
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
return [x, y]
return resolveAllelePair(locus, g[locus])
}
/**
* Whether a catalog entry belongs to the given E-family by NAME. The Schimmel
* entries split into two breeder groups that share the same E:'ef' token but
* differ by zygosity of the live animal:
* - 'Fuchsschimmel' family (ef/e het) → only *fuchsschimmel entries
* (Goldfuchsschimmel, Kohlfuchsschimmel, …).
* - 'Schimmel' family (ef/ef hom) → the pure roan entries whose name ends
* in 'schimmel' but NOT 'fuchsschimmel' (Rotaugenschimmel, Orangeschimmel,
* Silberschimmel, …).
* GEN-5 (ticket 5826e8e2): this is why ef/e must NOT match a pure-Schimmel entry
* (Rotaugenschimmel) — a het Fuchsschimmel animal is a Goldfuchsschimmel.
*/
function entryInEFamily(entry: FarbschlagEntry, family: string): boolean {
if (entry.tokens.E === undefined) return false
const n = entry.name.toLowerCase()
if (family === 'Fuchsschimmel') return n.includes('fuchsschimmel')
if (family === 'Schimmel') return n.includes('schimmel') && !n.includes('fuchsschimmel')
// 'Fuchs' family: fox entries are E:'e' (no 'schimmel' in the name).
return !n.includes('schimmel')
}
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
function baseColourFor(g: Genotype): string | null {
const family = eFamily(g)
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
? (BASE_COLORS.find((e) => entryInEFamily(e, family) && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
@@ -242,13 +264,20 @@ function colourpointName(g: Genotype): string | null {
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
const agouti = resolvedPair(g, 'A').includes('A')
if (!agouti) {
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
// must derive its base generically like the A- branch, so e.g.
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
if (!agouti && eFamily(g) === null) {
const [g1, g2] = resolvedPair(g, 'G')
const grey = g1 === 'g' && g2 === 'g'
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam'
}
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
// Colourpoint base derivation: name the colour as if C were full, then prefix
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
@@ -314,12 +343,22 @@ export function genotypeToFarbschlag(g: Genotype): string {
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
const base = wildType()
const out = {} as Record<LocusKey, AllelePair>
// GEN-5 (ticket 5826e8e2): a *Fuchsschimmel variety is the HETEROZYGOUS ef/e
// animal (a Schimmel-modified Fox), whereas the pure *schimmel varieties
// (Rotaugen-/Orange-/Silberschimmel) are HOMOZYGOUS ef/ef. The E token is the
// shared phenotype letter 'ef'; the representative genotype must encode the
// right zygosity so each entry round-trips back to its own family.
const isFuchsschimmel = entry.name.toLowerCase().includes('fuchsschimmel')
for (const locus of LOCUS_ORDER) {
const token = entry.tokens[locus]
if (!token) {
out[locus] = base[locus]
continue
}
if (locus === 'E' && token === 'ef' && isFuchsschimmel) {
out[locus] = ['ef', 'e'] // het Fuchsschimmel (ef/e), not hom ef/ef
continue
}
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]

View File

@@ -236,25 +236,25 @@
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
@@ -272,7 +272,7 @@
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
@@ -290,7 +290,7 @@
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
@@ -302,7 +302,7 @@
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
@@ -332,7 +332,7 @@
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},

View File

@@ -236,25 +236,25 @@
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
"canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
"canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
@@ -272,7 +272,7 @@
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
"canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
@@ -290,7 +290,7 @@
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
"canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
@@ -302,7 +302,7 @@
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
@@ -332,7 +332,7 @@
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp",
"canonicalGenotype": "aa CC dd ee gg PP spsp",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},

View File

@@ -40,6 +40,38 @@ export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair
return rank(a) <= rank(b) ? [a, b] : [b, a]
}
/**
* GEN-5 — the breeder's UNKNOWN-allele rule (ticket 3e643ef1, confirmed by the
* Züchterin): an unknown allele '?' is a COPY of the known, visible partner
* allele until the gene is determined. So a single-unknown pair resolves to the
* homozygote of the KNOWN allele:
* A? → AA D? → DD [e,?] → ee [E,?] → EE [ef,?] → ef/ef
* Only when BOTH alleles are unknown is the locus genuinely undetermined; it then
* falls back to the wild-type reading (most-dominant colour allele, but the
* recessive UNMARKED allele for the Sp/Re/Sls markers so an unknown marker never
* implies Schecke/Rex/WP).
*
* This single rule is shared by phenotype/catalog resolution (catalog.ts) and the
* Punnett gamete weights (punnett.ts), so an unknown allele never invents a
* recessive phenotype (no phantom Dilute/efef/Unbekannt in offspring lists).
*/
export function resolveAllelePair(locus: LocusKey, pair: AllelePair): [string, string] {
const [a, b] = pair
const aUnknown = a === WILDCARD
const bUnknown = b === WILDCARD
if (!aUnknown && !bUnknown) return [a, b]
if (aUnknown && bUnknown) {
// Fully unknown: wild-type reading (markers default to the unmarked recessive).
const alleles = LOCI[locus].alleles
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
const fb = isMarker ? alleles[alleles.length - 1] : alleles[0]
return [fb, fb]
}
// Exactly one unknown → copy of the known partner allele (homozygous).
const known = aUnknown ? b : a
return [known, known]
}
function assertAllele(locus: LocusKey, allele: string): void {
if (allele === WILDCARD) return
if (!LOCI[locus].alleles.includes(allele)) {
@@ -180,6 +212,11 @@ function normalizeToken(tok: string): string | null {
let t = tok
if (t === 'WP') t = 'Slsl'
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
// GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
// dense marker is a shade qualifier, not a separate allele — strip it BEFORE
// the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
// (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
t = t.replace(/uw\[d\]/gi, 'uw')
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
@@ -284,3 +321,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
export function hasUnknown(g: Genotype): boolean {
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
}
/** Per-locus note about an allele that parent-inference filled in. */
export interface ParentInferredLocus {
readonly locus: LocusKey
/** The allele a homozygous parent forced onto the child. */
readonly allele: string
/** 'father' | 'mother' — which parent was homozygous. */
readonly from: 'father' | 'mother'
}
export interface ParentInferenceResult {
readonly genotype: Genotype
/** Loci whose unknown allele was resolved from a parent (empty = nothing changed). */
readonly inferred: ParentInferredLocus[]
}
/**
* GEN-5 (tickets cc9ea3fe / 1a508c04, breeder rule via Mendel): a child's UNKNOWN
* allele can be filled in from a HOMOZYGOUS parent, which can only pass that one
* allele. E.g. Mamta Mini stored E = [E,?]; her father Geely is ee (homozygous
* fox) so he must pass an 'e' — the child's unknown E allele therefore IS 'e',
* giving Ee (not the copy-of-known EE default).
*
* Rule, per locus, ONLY for an allele still unknown ('?') in the child:
* - if a parent is homozygous (both alleles equal and known), that allele is
* forced onto the child's unknown slot.
* - the father is checked first; if he doesn't resolve it, the mother is tried.
* - a parent allele is only accepted if it is one the child could legitimately
* carry at that locus (it always is for a real parent, but we guard anyway).
* Pairs with no unknown, or where no parent is homozygous, are left untouched
* (still subject to the copy-of-known display/colour rule elsewhere).
*/
export function inferUnknownsFromParents(
child: Genotype,
father: Genotype | null | undefined,
mother: Genotype | null | undefined,
): ParentInferenceResult {
const out = {} as Record<LocusKey, AllelePair>
const inferred: ParentInferredLocus[] = []
for (const locus of LOCUS_ORDER) {
const [a, b] = child[locus]
const aUnknown = a === WILDCARD
const bUnknown = b === WILDCARD
if (!aUnknown && !bUnknown) {
out[locus] = child[locus]
continue
}
const homForced = (p: Genotype | null | undefined): string | null => {
if (!p) return null
const [pa, pb] = p[locus]
if (pa === WILDCARD || pb === WILDCARD) return null
return pa === pb ? pa : null
}
const fatherAllele = homForced(father)
const forced = fatherAllele ?? homForced(mother)
const from: 'father' | 'mother' = fatherAllele ? 'father' : 'mother'
if (forced && (aUnknown !== bUnknown)) {
// Exactly one unknown slot → fill it with the forced parent allele.
const known = aUnknown ? b : a
out[locus] = canonicalPair(locus, known, forced)
inferred.push({ locus, allele: forced, from })
} else if (forced && aUnknown && bUnknown) {
// Both unknown but a parent is homozygous → that allele is certain on one
// slot; the other stays unknown.
out[locus] = canonicalPair(locus, forced, WILDCARD)
inferred.push({ locus, allele: forced, from })
} else {
out[locus] = child[locus]
}
}
return { genotype: makeGenotype(out), inferred }
}

View File

@@ -21,9 +21,16 @@ export {
fromJSON,
hasUnknown,
displayGenotypeSafe,
resolveAllelePair,
inferUnknownsFromParents,
WILDCARD,
} from './genotype'
export type { Genotype, AllelePair } from './genotype'
export type {
Genotype,
AllelePair,
ParentInferenceResult,
ParentInferredLocus,
} from './genotype'
export { LOCI, LOCUS_ORDER } from './loci'
export type { LocusKey, LocusDef } from './loci'

View File

@@ -10,12 +10,12 @@
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
* before combining, so a parent known only by phenotype can still be paired.
*/
import { add, frac, multiply, ONE, type Fraction } from './fraction'
import { LOCI, LOCUS_ORDER, type LocusKey } from './loci'
import { add, frac, multiply, type Fraction, ONE } from './fraction'
import { LOCUS_ORDER, type LocusKey } from './loci'
import {
canonicalPair,
resolveAllelePair,
toDisplayString,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
@@ -26,22 +26,24 @@ export interface DistEntry<T> {
readonly probability: Fraction
}
/** Expand a (possibly wildcard) parent allele pair into weighted concrete alleles. */
/**
* Expand a (possibly partly-unknown) parent allele pair into weighted concrete
* gamete alleles.
*
* GEN-5 (ticket 3e643ef1, breeder rule): an unknown allele '?' is a COPY of the
* known partner allele (A?→AA, D?→DD, [e,?]→ee), NOT a uniform spread over the
* whole allele set. Spreading wrongly invented recessive gametes (d, ef, e) that
* produced impossible offspring colours — phantom Dilute, efef Schimmel and
* 'Unbekannter Farbschlag' in the expected-litter list. After resolution each of
* the two (now concrete) alleles contributes 1/2 of the gamete. A fully-unknown
* locus resolves to the wild-type homozygote (see resolveAllelePair).
*/
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
const weights = new Map<string, Fraction>()
const addWeight = (allele: string, w: Fraction) => {
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
}
const alleles = LOCI[locus].alleles
for (const a of pair) {
if (a === WILDCARD) {
// Unknown allele: uniform over the locus set, each contributing 1/2 of the gamete.
const share = frac(1, 2 * alleles.length)
for (const concrete of alleles) addWeight(concrete, share)
} else {
addWeight(a, frac(1, 2))
}
}
for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
return weights
}

View File

@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import {
fromDisplayString,
genotypeToFarbschlag,
displayGenotypeSafe,
toDisplayString,
hasUnknown,
inferUnknownsFromParents,
} from '../genetics'
import type { Gender, GerbilStatus } from '../api/types'
import FarbschlagImage from '../components/FarbschlagImage'
import GerbilHealthTab from '../components/GerbilHealthTab'
@@ -138,6 +145,37 @@ export default function GerbilDetailPage() {
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
const geno = describeGenotype(g.genotype)
// GEN-5 (Tickets cc9ea3fe / 1a508c04): unbekannte Gencode-Buchstaben aus einem
// reinerbigen Elternteil ergänzen (Mendel: ein reinerbiger Elternteil kann nur
// dieses eine Allel vererben). Greift nur, wenn der Genotyp ein '-' enthält UND
// mindestens ein Elternteil mit Genotyp am eigenen Wurf hinterlegt ist.
const genoInferred = (() => {
if (!g.genotype) return null
let child
try {
child = fromDisplayString(g.genotype)
} catch {
return null
}
if (!hasUnknown(child)) return null
const parse = (s: string | null | undefined) => {
if (!s) return null
try {
return fromDisplayString(s)
} catch {
return null
}
}
const f = parse(father.data?.genotype)
const m = parse(mother.data?.genotype)
if (!f && !m) return null
const res = inferUnknownsFromParents(child, f, m)
if (res.inferred.length === 0) return null
const display = toDisplayString(res.genotype)
if (display === geno?.display) return null
return { display, farbschlag: genotypeToFarbschlag(res.genotype) }
})()
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
@@ -342,14 +380,29 @@ export default function GerbilDetailPage() {
<dl className="ak-kvlist">
<Kv label={t.fields.genotype}>
<code className="ak-genotype">{geno.display}</code>
{genoInferred && (
<span className="ak-inferred" title={t.detail.genotypeInferredTitle}>
{' → '}
<code className="ak-genotype">{genoInferred.display}</code>{' '}
<small className="ak-inferred-chip"> {t.detail.genotypeInferred}</small>
</span>
)}
</Kv>
<Kv label={t.detail.resolvedPrefix}>
{geno.farbschlag}
{storedColorName &&
geno.farbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
{(() => {
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
return (
<>
{resolvedFarbschlag}
{storedColorName &&
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !==
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
)
})()}
</Kv>
</dl>
) : (

View File

@@ -286,6 +286,20 @@
color: var(--ak-warn);
font-weight: 600;
}
/* GEN-5: parent-inferred genotype hint (Ee[-] → Ee aus den Eltern ergänzt). */
.ak-inferred {
white-space: nowrap;
}
.ak-inferred-chip {
display: inline-block;
background: var(--ak-tan);
color: var(--color-text);
border-radius: 999px;
padding: 1px 9px;
font-weight: 600;
font-size: 12px;
vertical-align: middle;
}
/* ---------- Character chips ---------- */
.ak-cgroup {

View File

@@ -106,6 +106,10 @@ export const de = {
resolvedPrefix: 'Errechnet',
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
genotypeNotSet: 'Kein Genotyp hinterlegt.',
// GEN-5: ein unbekannter Gencode-Buchstabe wurde aus einem reinerbigen
// Elternteil ergänzt (z. B. Vater ee ⇒ Kind erbt e). %s = aufgelöster Genotyp.
genotypeInferred: 'Aus den Eltern ergänzt',
genotypeInferredTitle: 'Ein unbekanntes Gen wurde aus einem reinerbigen Elternteil abgeleitet.',
testMating: 'Probeverpaarung',
edit: 'Bearbeiten',
back: 'Zurück zur Liste',

View File

@@ -28,6 +28,11 @@ r = g.parse("uwuw")
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
r = g.parse("uw[d]uw[d]")
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
# GEN-5 (ticket 5151ab20 / Vance): the het 'Uwuw[d]' (one Underwhite, one dense
# underwhite) must parse to the G locus as Gg — never crash on the [d] modifier.
r = g.parse("aa Cc[chm] D- ee Uwuw[d] PP spsp")
check("Uwuw[d] -> Gg (Vance, het dense underwhite)", r["mapped8locus"].get("G") == ["G", "g"])
check("Uwuw[d] full string: nothing unmapped", r["unmappedTokens"] == [])
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
check("Gg identical to Uwuw at G locus",