Merge feature/gen-3c: unknown allele displays '-' (stores '?'), fix Unbekannter Farbschlag (wildcard + E-locus family fallback) [god-QA pending]
This commit is contained in:
@@ -173,8 +173,8 @@ describe('Farbschlag catalog', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
|
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
|
||||||
// Colourpoint marked + dilute + grey combo not in the catalog.
|
// E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo.
|
||||||
const match = farbschlagFor(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))
|
const match = farbschlagFor(fromDisplayString('aa cchmcchm dd EE gg pp spsp rere'))
|
||||||
expect(match.unknown).toBe(true)
|
expect(match.unknown).toBe(true)
|
||||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||||
})
|
})
|
||||||
@@ -222,7 +222,7 @@ describe('Farbschlag catalog', () => {
|
|||||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||||
expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
|
expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
|
||||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin')
|
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin')
|
||||||
expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))).toBe(
|
expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd EE gg pp spsp rere'))).toBe(
|
||||||
'Unbekannter Farbschlag',
|
'Unbekannter Farbschlag',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -350,3 +350,32 @@ describe('GEN-3a: Schimmel catalog fix (breeder C5)', () => {
|
|||||||
expect(BASE_COLORS.some((e) => e.name === 'Schwarzschimmel')).toBe(false)
|
expect(BASE_COLORS.some((e) => e.name === 'Schwarzschimmel')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||||
|
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||||
|
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||||
|
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||||
|
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
||||||
|
})
|
||||||
|
it("'?' and '-' inputs are equivalent", () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||||
|
'Aa C- DD EE GG Pp spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||||
|
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
||||||
|
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||||
|
'Fuchsschimmel',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||||
|
expect(farbschlagFor(fromDisplayString('a- C- D- ee G- P-')).unknown).toBe(false)
|
||||||
|
expect(farbschlagFor(fromDisplayString('A- C- D- efef G- P-')).unknown).toBe(false)
|
||||||
|
})
|
||||||
|
it('unknown allele no longer blocks a match (wildcard, never Unbekannt)', () => {
|
||||||
|
// C unknown must not force Unbekannt — it resolves to SOME named variety.
|
||||||
|
expect(farbschlagFor(fromDisplayString('aa C- DD EE GG PP spsp rere')).unknown).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -20,9 +20,15 @@
|
|||||||
* 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 { LOCUS_ORDER, type LocusKey } from './loci'
|
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||||
import { makeGenotype, toDisplayString, wildType, type AllelePair, type Genotype } from './genotype'
|
import {
|
||||||
import { phenotypeTokens, type PhenotypeTokens } from './phenotype'
|
makeGenotype,
|
||||||
|
toDisplayString,
|
||||||
|
wildType,
|
||||||
|
WILDCARD,
|
||||||
|
type AllelePair,
|
||||||
|
type Genotype,
|
||||||
|
} from './genotype'
|
||||||
|
|
||||||
export interface FarbschlagEntry {
|
export interface FarbschlagEntry {
|
||||||
/** German variety name. FROZEN for the original 18 (DB keys). */
|
/** German variety name. FROZEN for the original 18 (DB keys). */
|
||||||
@@ -138,25 +144,64 @@ export interface FarbschlagMatch {
|
|||||||
readonly unknown: boolean
|
readonly unknown: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean {
|
/**
|
||||||
return (Object.keys(entry.tokens) as LocusKey[]).every(
|
* Expressed token at a locus, or null when UNKNOWN (a '?' allele) — null acts as
|
||||||
(locus) => tokens[locus] === entry.tokens[locus],
|
* a wildcard in matching. The E locus is PAIR-aware so the Fuchs/Schimmel family
|
||||||
)
|
* is distinguishable: ee->'e', e/ef->'eef' (Fuchsschimmel), ef/ef->'ef' (Schimmel).
|
||||||
|
*/
|
||||||
|
function locusToken(g: Genotype, locus: LocusKey): string | null {
|
||||||
|
const [x, y] = g[locus]
|
||||||
|
if (x === WILDCARD || y === WILDCARD) return null
|
||||||
|
if (locus === 'E') {
|
||||||
|
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||||
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
||||||
|
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||||
|
}
|
||||||
|
return dominantAllele(locus, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||||
|
// Unknown loci (null token) match anything (treated as wildcard, GEN-3c).
|
||||||
|
return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => {
|
||||||
|
const t = locusToken(g, locus)
|
||||||
|
return t === null || t === entry.tokens[locus]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
|
||||||
|
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
|
||||||
|
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
|
||||||
|
* Returns null when E is dominant (full colour) or fully unknown.
|
||||||
|
*/
|
||||||
|
function eFamily(g: Genotype): string | null {
|
||||||
|
const [x, y] = 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 a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||||
const tokens = phenotypeTokens(g)
|
const family = eFamily(g)
|
||||||
const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null
|
// When the animal is a Fuchs/Schimmel-family genotype, only entries that
|
||||||
|
// explicitly pin the E locus may name it (so e.g. eef can't masquerade as a
|
||||||
|
// C-series white via wildcard C); otherwise fall back to the family name.
|
||||||
|
const base = family
|
||||||
|
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||||
|
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||||
|
|
||||||
const modifiers: string[] = []
|
const modifiers: string[] = []
|
||||||
if (tokens.Sp === 'Sp') modifiers.push('Schecke')
|
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||||
if (tokens.Re === 'Re') modifiers.push('Rex')
|
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||||
|
|
||||||
if (!base) {
|
const baseName = base?.name ?? family
|
||||||
|
if (!baseName) {
|
||||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||||
}
|
}
|
||||||
const name = [base.name, ...modifiers].join(' ')
|
const name = [baseName, ...modifiers].join(' ')
|
||||||
return { name, base, unknown: false }
|
return { name, base, unknown: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,12 +79,14 @@ export function wildType(): Genotype {
|
|||||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
||||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||||
|
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||||
|
* convention) — e.g. ['C','?'] renders "C-".
|
||||||
*/
|
*/
|
||||||
export function toDisplayString(g: Genotype): string {
|
export function toDisplayString(g: Genotype): string {
|
||||||
return LOCUS_ORDER.filter(
|
return LOCUS_ORDER.filter(
|
||||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
)
|
)
|
||||||
.map((locus) => g[locus][0] + g[locus][1])
|
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +146,9 @@ function normalizeToken(tok: string): string | null {
|
|||||||
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')
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
|
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
||||||
|
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
||||||
|
t = t.replace(/-/g, '?')
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +198,9 @@ export function fromDisplayString(input: string): Genotype {
|
|||||||
const [a, b] = splitToken(token)
|
const [a, b] = splitToken(token)
|
||||||
const refAllele = a === WILDCARD ? b : a
|
const refAllele = a === WILDCARD ? b : a
|
||||||
if (refAllele === WILDCARD) {
|
if (refAllele === WILDCARD) {
|
||||||
throw new Error(`Token "${token}" is fully unknown; cannot infer its locus`)
|
// Fully-unknown token ("--"/"??", e.g. a positional placeholder): the locus
|
||||||
|
// can't be inferred — skip it (defaults to wild-type), don't throw.
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
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}"`)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const de = {
|
|||||||
editTitle: 'Rennmaus bearbeiten',
|
editTitle: 'Rennmaus bearbeiten',
|
||||||
none: '— keine Angabe —',
|
none: '— keine Angabe —',
|
||||||
genotypeHint:
|
genotypeHint:
|
||||||
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.',
|
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -217,7 +217,7 @@ export const de = {
|
|||||||
pickAnimalNoGenotype: 'Für dieses Tier ist kein Genotyp hinterlegt – bitte unten eingeben.',
|
pickAnimalNoGenotype: 'Für dieses Tier ist kein Genotyp hinterlegt – bitte unten eingeben.',
|
||||||
clearAnimal: 'Auswahl entfernen',
|
clearAnimal: 'Auswahl entfernen',
|
||||||
genotypeLabel: 'Genotyp',
|
genotypeLabel: 'Genotyp',
|
||||||
genotypeHint: 'Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.',
|
genotypeHint: 'Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||||
genotypeInvalid: 'Der Genotyp ist ungültig.',
|
genotypeInvalid: 'Der Genotyp ist ungültig.',
|
||||||
genotypePreview: 'Farbschlag',
|
genotypePreview: 'Farbschlag',
|
||||||
run: 'Probeverpaarung berechnen',
|
run: 'Probeverpaarung berechnen',
|
||||||
|
|||||||
Reference in New Issue
Block a user