Merge branch 'worktree-agent-a7281df1f2cc3f1f1'
# Conflicts: # gerbil-manager-web/src/genetics/catalog.ts # gerbil-manager-web/src/genetics/genotype.ts # gerbil-manager-web/src/genetics/punnett.ts
This commit is contained in:
@@ -400,25 +400,28 @@ public class ApplicationContext : DbContext
|
|||||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
("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) ---
|
// --- Hell variants (IDs 38-48) ---
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
("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),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
("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),
|
("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),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
("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 / Siam / CP- series (IDs 51-66) ---
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||||
|
|||||||
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -483,28 +483,28 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
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",
|
Name = "Polarfuchsschimmel",
|
||||||
SortOrder = 37
|
SortOrder = 37
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
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",
|
Name = "Algierfuchsschimmel",
|
||||||
SortOrder = 38
|
SortOrder = 38
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
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",
|
Name = "Kohlfuchsschimmel",
|
||||||
SortOrder = 39
|
SortOrder = 39
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
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",
|
Name = "Blaufuchsschimmel",
|
||||||
SortOrder = 40
|
SortOrder = 40
|
||||||
},
|
},
|
||||||
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
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",
|
Name = "Goldfuchsschimmel",
|
||||||
SortOrder = 43
|
SortOrder = 43
|
||||||
},
|
},
|
||||||
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
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",
|
Name = "Rotfuchsschimmel",
|
||||||
SortOrder = 46
|
SortOrder = 46
|
||||||
},
|
},
|
||||||
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
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",
|
Name = "Kohlfuchsschimmel, hell",
|
||||||
SortOrder = 48
|
SortOrder = 48
|
||||||
},
|
},
|
||||||
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
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",
|
Name = "Dilute Blaufuchs",
|
||||||
SortOrder = 53
|
SortOrder = 53
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
wildType,
|
wildType,
|
||||||
extractGenotypeFlags,
|
extractGenotypeFlags,
|
||||||
displayGenotypeSafe,
|
displayGenotypeSafe,
|
||||||
|
resolveAllelePair,
|
||||||
|
inferUnknownsFromParents,
|
||||||
} from '../genotype'
|
} from '../genotype'
|
||||||
import { combineLocus } from '../punnett'
|
import { combineLocus } from '../punnett'
|
||||||
import { LOCI, type LocusKey } from '../loci'
|
import { LOCI, type LocusKey } from '../loci'
|
||||||
@@ -239,10 +241,11 @@ describe('Farbschlag catalog', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Partially-unknown parents (wildcards)', () => {
|
describe('Partially-unknown parents (wildcards)', () => {
|
||||||
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
|
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
|
||||||
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
|
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
|
||||||
// = effectively 3/4 A, 1/4 a ; mother always a.
|
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
|
||||||
// Offspring: 3/4 Aa, 1/4 aa.
|
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
|
||||||
|
// a recessive 'a' gamete the parent demonstrably does not show.)
|
||||||
const father = makeGenotype({
|
const father = makeGenotype({
|
||||||
A: ['A', '?'],
|
A: ['A', '?'],
|
||||||
C: ['C', 'C'],
|
C: ['C', 'C'],
|
||||||
@@ -257,10 +260,10 @@ describe('Partially-unknown parents (wildcards)', () => {
|
|||||||
const mother = 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)
|
const result = breed(father, mother)
|
||||||
|
|
||||||
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
|
expect(result.offspring).toHaveLength(1)
|
||||||
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
|
expect(result.offspring[0].farbschlag).toBe('Agouti')
|
||||||
expect(agouti.probability.text).toBe('3/4')
|
expect(result.offspring[0].probability.text).toBe('1')
|
||||||
expect(schwarz.probability.text).toBe('1/4')
|
expect(result.byFarbschlag.some((f) => f.farbschlag === 'Schwarz')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -603,7 +606,10 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
|||||||
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute 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 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 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 Gold')
|
||||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||||
})
|
})
|
||||||
@@ -628,10 +634,13 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
|||||||
|
|
||||||
it('Farbarten (categories) never appear as computed results', () => {
|
it('Farbarten (categories) never appear as computed results', () => {
|
||||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
// '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')
|
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)
|
// 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('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')
|
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
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)
|
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||||
@@ -789,3 +798,118 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
expect(displayGenotypeSafe('')).toBe('')
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
||||||
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
* 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 {
|
import {
|
||||||
makeGenotype,
|
makeGenotype,
|
||||||
|
resolveAllelePair,
|
||||||
toDisplayString,
|
toDisplayString,
|
||||||
wildType,
|
wildType,
|
||||||
WILDCARD,
|
|
||||||
type AllelePair,
|
type AllelePair,
|
||||||
type Genotype,
|
type Genotype,
|
||||||
} from './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: '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: '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 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/3g: c^chm colourpoint varieties ──
|
||||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||||
@@ -163,13 +167,10 @@ export interface FarbschlagMatch {
|
|||||||
* variety specifically.
|
* variety specifically.
|
||||||
*/
|
*/
|
||||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
|
||||||
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
|
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
|
||||||
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
|
// back to the wild-type reading (most-dominant colour / unmarked marker).
|
||||||
const alleles = LOCI[locus].alleles
|
const [x, y] = resolveAllelePair(locus, g[locus])
|
||||||
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))
|
|
||||||
if (locus === 'E') {
|
if (locus === 'E') {
|
||||||
if (x === y) return x // ee->'e', efef->'ef', EE->'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;
|
// 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).
|
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||||
*/
|
*/
|
||||||
function eFamily(g: Genotype): string | null {
|
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 === 'e') return 'Fuchs'
|
||||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
||||||
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
||||||
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
/** 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] {
|
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
||||||
const alleles = LOCI[locus].alleles
|
return resolveAllelePair(locus, g[locus])
|
||||||
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]
|
* 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. */
|
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
||||||
function baseColourFor(g: Genotype): string | null {
|
function baseColourFor(g: Genotype): string | null {
|
||||||
const family = eFamily(g)
|
const family = eFamily(g)
|
||||||
const base = family
|
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)
|
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||||
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||||
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
||||||
@@ -242,32 +264,20 @@ function colourpointName(g: Genotype): string | null {
|
|||||||
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
||||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||||
const agouti = resolvedPair(g, 'A').includes('A')
|
const agouti = resolvedPair(g, 'A').includes('A')
|
||||||
if (!agouti) {
|
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
|
||||||
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
|
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
|
||||||
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
|
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
|
||||||
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
|
// must derive its base generically like the A- branch, so e.g.
|
||||||
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
|
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
|
||||||
// dd dilute or ee Fuchs) is named from the resolved base colour, so
|
if (!agouti && eFamily(g) === null) {
|
||||||
// 'aa cchm dd ee gg' no longer collapses to Zobel.
|
const [g1, g2] = resolvedPair(g, 'G')
|
||||||
const [d1, d2] = resolvedPair(g, 'D')
|
const grey = g1 === 'g' && g2 === 'g'
|
||||||
const wildD = d1 === 'D' && d2 === 'D'
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||||
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel)
|
return bothCchm ? 'Marder' : 'Siam'
|
||||||
if (wildD && fullExtension) {
|
|
||||||
const [g1, g2] = resolvedPair(g, 'G')
|
|
||||||
const grey = g1 === 'g' && g2 === 'g'
|
|
||||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
|
||||||
return bothCchm ? 'Marder' : 'Siam'
|
|
||||||
}
|
|
||||||
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
|
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
|
||||||
if (!base) return null
|
|
||||||
const DILUTE = 'Dilute '
|
|
||||||
if (base.startsWith(DILUTE)) {
|
|
||||||
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
|
||||||
}
|
|
||||||
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
|
||||||
}
|
}
|
||||||
// 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'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
if (!base) return null
|
if (!base) return null
|
||||||
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
||||||
@@ -333,12 +343,22 @@ export function genotypeToFarbschlag(g: Genotype): string {
|
|||||||
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
||||||
const base = wildType()
|
const base = wildType()
|
||||||
const out = {} as Record<LocusKey, AllelePair>
|
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) {
|
for (const locus of LOCUS_ORDER) {
|
||||||
const token = entry.tokens[locus]
|
const token = entry.tokens[locus]
|
||||||
if (!token) {
|
if (!token) {
|
||||||
out[locus] = base[locus]
|
out[locus] = base[locus]
|
||||||
continue
|
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
|
// 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.
|
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||||
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||||
|
|||||||
@@ -236,25 +236,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
|
||||||
"sortOrder": 37,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
|
||||||
"sortOrder": 38,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
|
||||||
"sortOrder": 40,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
|
||||||
"sortOrder": 43,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
|
||||||
"sortOrder": 46,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||||
"sortOrder": 48,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Blaufuchs",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,25 +236,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
"canonicalGenotype": "AA CC DD ee[f] gg PP spsp",
|
||||||
"sortOrder": 37,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
"canonicalGenotype": "AA CC DD ee[f] GG PP spsp",
|
||||||
"sortOrder": 38,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
"canonicalGenotype": "aa CC DD ee[f] GG PP spsp",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
"canonicalGenotype": "aa CC DD ee[f] gg PP spsp",
|
||||||
"sortOrder": 40,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
"canonicalGenotype": "AA CC DD ee[f] GG pp spsp",
|
||||||
"sortOrder": 43,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
"canonicalGenotype": "aa CC DD ee[f] GG pp spsp",
|
||||||
"sortOrder": 46,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"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,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Blaufuchs",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
"canonicalGenotype": "aa CC dd ee gg PP spsp",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,6 +40,38 @@ export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair
|
|||||||
return rank(a) <= rank(b) ? [a, b] : [b, a]
|
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 {
|
function assertAllele(locus: LocusKey, allele: string): void {
|
||||||
if (allele === WILDCARD) return
|
if (allele === WILDCARD) return
|
||||||
if (!LOCI[locus].alleles.includes(allele)) {
|
if (!LOCI[locus].alleles.includes(allele)) {
|
||||||
@@ -180,27 +212,27 @@ function normalizeToken(tok: string): string | null {
|
|||||||
let t = tok
|
let t = tok
|
||||||
if (t === 'WP') t = 'Slsl'
|
if (t === 'WP') t = 'Slsl'
|
||||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||||
// GEN-3b (#32/#34): Underwhite == G locus. Strip the breeder's "[d]" (dense
|
// GEN-5: dense-underwhite modifier uw[d]/Uw[d] (G-locus). The German "[d]"
|
||||||
// underwhite) annotation from the uw/Uw token BEFORE aliasing to G/g, so that
|
// dense marker is a shade qualifier, not a separate allele — strip it BEFORE
|
||||||
// "Uwuw[d]" → "Gg" and "uw[d]uw[d]" → "gg" (mirrors tools/import/genotype.py
|
// the Uw→G alias so e.g. "Uwuw[d]" / "uw[d]uw[d]" parse as Gg / gg, not "Gg[d]"
|
||||||
// _rewrite_uw). Without this the "[d]" survived → splitToken("Gg[d]") threw and
|
// (which crashes splitToken). Mirrors tools/import/genotype.py _rewrite_uw.
|
||||||
// the frontend fell back to "Unbekannter Farbschlag" / leaked the raw uw token.
|
t = t.replace(/uw\[d\]/gi, 'uw')
|
||||||
t = t.replace(/(Uw|uw)\[d\]/g, '$1')
|
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||||
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
||||||
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||||
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||||
// #42 (E-locus e-dash): Fuchs (e) is RECESSIVE — a visible fox MUST be
|
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook
|
||||||
// homozygous "ee". The herdbook form "ee[-]" (fox allele + unknown E-type
|
// form "ee[-]" (fox allele + unknown E-type partner) therefore resolves to "ee" (Fuchs),
|
||||||
// second allele) therefore resolves to "ee" (Fuchs), NOT [e,?]; the recessive
|
// NOT [e,?] — the recessive phenotype implies homozygosity. A bare "e-"/"e[-]" (a lone
|
||||||
// phenotype implies homozygosity. A bare "e-" / "e[-]" (a single recessive
|
// recessive fox with an unknown partner) is genetically impossible and is rejected below.
|
||||||
// fox allele with an unknown partner) is genetically impossible and is left to
|
|
||||||
// be rejected by splitToken (invalid → genotypeInvalid path).
|
|
||||||
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
|
t = t.replace(/ee\[-\]/g, 'ee').replace(/ee-/g, 'ee')
|
||||||
// CR-1a: allele-prefixed bracket-unknown like cc[-]: when e[-]/c[-] is PRECEDED
|
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
||||||
// by a letter it is the second unknown allele in a 2-allele token. Lookbehind
|
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
||||||
// strips only the bracket part; the leading allele stays.
|
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
|
||||||
|
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
|
||||||
|
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
|
||||||
|
// leaving the leading allele intact for splitToken).
|
||||||
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||||
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
|
||||||
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
|
||||||
@@ -266,12 +298,14 @@ export function fromDisplayString(input: string): Genotype {
|
|||||||
const locus = ALLELE_TO_LOCUS[refAllele]
|
const locus = ALLELE_TO_LOCUS[refAllele]
|
||||||
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
||||||
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
if (acc[locus]) throw new Error(`Locus ${locus} given twice`)
|
||||||
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" →
|
// #42: a lone recessive Fuchs allele with an unknown partner ("e-"/"e[-]" → [e,?]) is
|
||||||
// [e,?]) is genetically impossible — fox is recessive, so a fox allele is
|
// genetically impossible — fox is recessive, so a fox allele is only visible homozygous
|
||||||
// only visible homozygous (ee, written "ee[-]"). Reject it so the UI surfaces
|
// ("ee", written "ee[-]"). Reject it so the UI surfaces the invalid-genotype message
|
||||||
// the genotypeInvalid message instead of silently mis-computing the colour.
|
// instead of silently mis-computing the colour. ("ee[-]" was already normalized to "ee".)
|
||||||
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
|
if (locus === 'E' && ((a === 'e' && b === WILDCARD) || (a === WILDCARD && b === 'e'))) {
|
||||||
throw new Error(`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`)
|
throw new Error(
|
||||||
|
`Invalid E-locus token "${token}": lone recessive "e" with unknown partner (use "ee[-]" for Fuchs or "E-" for unknown)`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
acc[locus] = canonicalPair(locus, a, b)
|
acc[locus] = canonicalPair(locus, a, b)
|
||||||
}
|
}
|
||||||
@@ -301,3 +335,75 @@ export function displayGenotypeSafe(raw: string | null | undefined): string {
|
|||||||
export function hasUnknown(g: Genotype): boolean {
|
export function hasUnknown(g: Genotype): boolean {
|
||||||
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,9 +21,16 @@ export {
|
|||||||
fromJSON,
|
fromJSON,
|
||||||
hasUnknown,
|
hasUnknown,
|
||||||
displayGenotypeSafe,
|
displayGenotypeSafe,
|
||||||
|
resolveAllelePair,
|
||||||
|
inferUnknownsFromParents,
|
||||||
WILDCARD,
|
WILDCARD,
|
||||||
} from './genotype'
|
} from './genotype'
|
||||||
export type { Genotype, AllelePair } from './genotype'
|
export type {
|
||||||
|
Genotype,
|
||||||
|
AllelePair,
|
||||||
|
ParentInferenceResult,
|
||||||
|
ParentInferredLocus,
|
||||||
|
} from './genotype'
|
||||||
|
|
||||||
export { LOCI, LOCUS_ORDER } from './loci'
|
export { LOCI, LOCUS_ORDER } from './loci'
|
||||||
export type { LocusKey, LocusDef } from './loci'
|
export type { LocusKey, LocusDef } from './loci'
|
||||||
|
|||||||
@@ -10,72 +10,40 @@
|
|||||||
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
|
* Wildcard ("?") alleles are expanded uniformly over the locus' allele set
|
||||||
* before combining, so a parent known only by phenotype can still be paired.
|
* before combining, so a parent known only by phenotype can still be paired.
|
||||||
*/
|
*/
|
||||||
import { add, frac, multiply, ONE, type Fraction } from './fraction'
|
import { add, frac, multiply, type Fraction, ONE } from './fraction'
|
||||||
import { dominanceRank, LOCI, LOCUS_ORDER, type LocusKey } from './loci'
|
import { LOCUS_ORDER, type LocusKey } from './loci'
|
||||||
import {
|
import {
|
||||||
canonicalPair,
|
canonicalPair,
|
||||||
|
resolveAllelePair,
|
||||||
toDisplayString,
|
toDisplayString,
|
||||||
WILDCARD,
|
|
||||||
type AllelePair,
|
type AllelePair,
|
||||||
type Genotype,
|
type Genotype,
|
||||||
} from './genotype'
|
} from './genotype'
|
||||||
|
|
||||||
/**
|
|
||||||
* #37/#39/#40/#41: which concrete alleles an UNKNOWN partner allele may actually be,
|
|
||||||
* given the KNOWN allele it is paired with at this locus.
|
|
||||||
*
|
|
||||||
* A hidden allele is constrained by the recorded (visible) one:
|
|
||||||
* 1. It can NEVER be more dominant than the known allele — otherwise the animal's
|
|
||||||
* phenotype would be different from what the breeder recorded. So the unknown
|
|
||||||
* only ranges over alleles with dominance rank >= rank(known) (equal or more
|
|
||||||
* recessive). This kills impossible more-dominant offspring morphs.
|
|
||||||
* 2. It can never be an allele that is VISIBLE in the heterozygote, unless the
|
|
||||||
* animal already expresses it. At the E locus 'ef' (Schimmel/roan) shows even
|
|
||||||
* heterozygously, so a non-Schimmel animal (known E or e) cannot secretly carry
|
|
||||||
* 'ef'. Excluding it removes the phantom Schimmel/efef predictions (#41).
|
|
||||||
*
|
|
||||||
* When BOTH alleles are unknown the locus is genuinely unconstrained → full set.
|
|
||||||
*/
|
|
||||||
function unknownPartnerOptions(locus: LocusKey, known: string): readonly string[] {
|
|
||||||
const alleles = LOCI[locus].alleles
|
|
||||||
if (known === WILDCARD) return alleles // fully unknown locus: any allele
|
|
||||||
const knownRank = dominanceRank(locus, known)
|
|
||||||
return alleles.filter((a) => {
|
|
||||||
if (dominanceRank(locus, a) < knownRank) return false // can't outrank the visible allele
|
|
||||||
// E-locus 'ef' is visible in het: only possible if the animal is itself Schimmel.
|
|
||||||
if (locus === 'E' && a === 'ef' && known !== 'ef') return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A probability distribution over outcomes of type T (keyed by a string). */
|
/** A probability distribution over outcomes of type T (keyed by a string). */
|
||||||
export interface DistEntry<T> {
|
export interface DistEntry<T> {
|
||||||
readonly value: T
|
readonly value: T
|
||||||
readonly probability: Fraction
|
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> {
|
function parentAlleleWeights(locus: LocusKey, pair: AllelePair): Map<string, Fraction> {
|
||||||
const weights = new Map<string, Fraction>()
|
const weights = new Map<string, Fraction>()
|
||||||
const addWeight = (allele: string, w: Fraction) => {
|
const addWeight = (allele: string, w: Fraction) => {
|
||||||
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
weights.set(allele, add(weights.get(allele) ?? frac(0, 1), w))
|
||||||
}
|
}
|
||||||
// The "other" allele of the pair tells us what an unknown is allowed to be:
|
for (const a of resolveAllelePair(locus, pair)) addWeight(a, frac(1, 2))
|
||||||
// an unknown partner is constrained by the known visible allele (see
|
|
||||||
// unknownPartnerOptions), not blown up uniformly over every allele.
|
|
||||||
const [a0, a1] = pair
|
|
||||||
for (let i = 0; i < 2; i++) {
|
|
||||||
const a = pair[i]
|
|
||||||
if (a === WILDCARD) {
|
|
||||||
const known = i === 0 ? a1 : a0
|
|
||||||
const options = unknownPartnerOptions(locus, known)
|
|
||||||
// Unknown allele contributes 1/2 of the gamete, split over its possible values.
|
|
||||||
const share = frac(1, 2 * options.length)
|
|
||||||
for (const concrete of options) addWeight(concrete, share)
|
|
||||||
} else {
|
|
||||||
addWeight(a, frac(1, 2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return weights
|
return weights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import { listColorVarieties, listContacts, listEnclosures, listLitters } from '.
|
|||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
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 type { Gender, GerbilStatus } from '../api/types'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
|
import GerbilAcquisitionSection from '../components/GerbilAcquisitionSection'
|
||||||
@@ -140,6 +147,37 @@ export default function GerbilDetailPage() {
|
|||||||
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
const showEnclosure = g.status !== 'Deceased' && g.status !== 'GivenAway'
|
||||||
|
|
||||||
const geno = describeGenotype(g.genotype)
|
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 lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||||
|
|
||||||
@@ -346,14 +384,29 @@ export default function GerbilDetailPage() {
|
|||||||
<dl className="ak-kvlist">
|
<dl className="ak-kvlist">
|
||||||
<Kv label={t.fields.genotype}>
|
<Kv label={t.fields.genotype}>
|
||||||
<code className="ak-genotype">{geno.display}</code>
|
<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>
|
||||||
<Kv label={t.detail.resolvedPrefix}>
|
<Kv label={t.detail.resolvedPrefix}>
|
||||||
{geno.farbschlag}
|
{(() => {
|
||||||
{storedColorName &&
|
const resolvedFarbschlag = (genoInferred ?? geno).farbschlag
|
||||||
geno.farbschlag !== de.genetics.unknownFarbschlag &&
|
return (
|
||||||
storedColorName !== geno.farbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
<>
|
||||||
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
{resolvedFarbschlag}
|
||||||
)}
|
{storedColorName &&
|
||||||
|
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
|
||||||
|
storedColorName !==
|
||||||
|
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
|
||||||
|
<small className="ak-mismatch"> ⚠ {t.detail.farbschlagMismatch}</small>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</Kv>
|
</Kv>
|
||||||
</dl>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -286,6 +286,20 @@
|
|||||||
color: var(--ak-warn);
|
color: var(--ak-warn);
|
||||||
font-weight: 600;
|
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 ---------- */
|
/* ---------- Character chips ---------- */
|
||||||
.ak-cgroup {
|
.ak-cgroup {
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ export const de = {
|
|||||||
resolvedPrefix: 'Errechnet',
|
resolvedPrefix: 'Errechnet',
|
||||||
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
|
farbschlagMismatch: 'Weicht vom eingetragenen Farbschlag ab.',
|
||||||
genotypeNotSet: 'Kein Genotyp hinterlegt.',
|
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',
|
testMating: 'Probeverpaarung',
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
back: 'Zurück zur Liste',
|
back: 'Zurück zur Liste',
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ r = g.parse("uwuw")
|
|||||||
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
|
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
|
||||||
r = g.parse("uw[d]uw[d]")
|
r = g.parse("uw[d]uw[d]")
|
||||||
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
|
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)
|
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
|
||||||
check("Gg identical to Uwuw at G locus",
|
check("Gg identical to Uwuw at G locus",
|
||||||
|
|||||||
Reference in New Issue
Block a user