FEAT: genotype parsing resilience and displayGenotypeSafe format helper

This commit is contained in:
2026-06-08 21:19:11 +02:00
parent 7d8166c555
commit 04d189bd2f
5 changed files with 83 additions and 7 deletions

View File

@@ -16,6 +16,7 @@ import {
fromJSON, fromJSON,
wildType, wildType,
extractGenotypeFlags, extractGenotypeFlags,
displayGenotypeSafe,
} from '../genotype' } from '../genotype'
import { combineLocus } from '../punnett' import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci' import { LOCI, type LocusKey } from '../loci'
@@ -686,4 +687,48 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.Sp).toEqual(['Sp', 'sp']) expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp') expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
}) })
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
it('handles blank c locus and c[-] resiliently without crashing', () => {
// Vandana's genotype
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
const g = fromDisplayString(rawVandana)
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
// cchmc allele
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
expect(g2.C).toEqual(['cchm', '?'])
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
// c[-] and blank c standalone
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
})
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
// 1. Parseable with unknown alleles, replacing ? with -
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
// 2. Parseable with trailing ?? (stripped completely)
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
// 4. Handles null/undefined/empty
expect(displayGenotypeSafe(null)).toBe('')
expect(displayGenotypeSafe(undefined)).toBe('')
expect(displayGenotypeSafe('')).toBe('')
})
}) })

View File

@@ -192,6 +192,11 @@ function normalizeToken(tok: string): string | null {
// the generic [-]→? rule below (which makes the bracket-dash a wildcard, // the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken). // 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(/^c\[-\]$/g, '??')
t = t.replace(/^c\?$/g, '??')
t = t.replace(/^c$/g, '??')
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'. // GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
t = t.replace(/-/g, '?') t = t.replace(/-/g, '?')
@@ -257,6 +262,25 @@ export function fromDisplayString(input: string): Genotype {
return makeGenotype({ ...base, ...acc }) return makeGenotype({ ...base, ...acc })
} }
export function displayGenotypeSafe(raw: string | null | undefined): string {
if (!raw) return ''
try {
const parsed = fromDisplayString(raw)
return toDisplayString(parsed)
} catch {
// Fallback path:
return raw
.trim()
.split(/\s+/)
.filter((tok) => {
const norm = normalizeToken(tok)
return norm !== null && norm !== '?' && norm !== '??'
})
.map((tok) => tok.replace(/\?/g, '-'))
.join(' ')
}
}
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)
} }

View File

@@ -20,6 +20,7 @@ export {
toJSON, toJSON,
fromJSON, fromJSON,
hasUnknown, hasUnknown,
displayGenotypeSafe,
WILDCARD, WILDCARD,
} from './genotype' } from './genotype'
export type { Genotype, AllelePair } from './genotype' export type { Genotype, AllelePair } from './genotype'

View File

@@ -6,7 +6,7 @@ import { listLitters as listLittersPaged } from '../api/litters'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
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 { fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import Charakterbogen from '../components/Charakterbogen' import Charakterbogen from '../components/Charakterbogen'
// FEAT-6 (Oscar): Tab-Inhalte + Profilfoto // FEAT-6 (Oscar): Tab-Inhalte + Profilfoto
@@ -22,10 +22,10 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
if (!genotype || !genotype.trim()) return null if (!genotype || !genotype.trim()) return null
try { try {
const g = fromDisplayString(genotype) const g = fromDisplayString(genotype)
return { display: toDisplayString(g), farbschlag: genotypeToFarbschlag(g) } return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) }
} catch { } catch {
// Unparseable: show the raw value, no derived Farbschlag. // Unparseable: show the safely-rendered raw value, no derived Farbschlag.
return { display: genotype, farbschlag: de.genetics.unknownFarbschlag } return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag }
} }
} }

View File

@@ -29,7 +29,7 @@ import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon' import GerbilIcon from '../components/GerbilIcon'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
ancestorsAt, ancestorsAt,
@@ -424,7 +424,7 @@ function PedigreeCard({
style={chip ? { background: chip.bg, color: chip.fg } : undefined} style={chip ? { background: chip.bg, color: chip.fg } : undefined}
title={farbschlag} title={farbschlag}
> >
{g.genotype || farbschlag} {g.genotype ? displayGenotypeSafe(g.genotype) : farbschlag}
</span> </span>
)} )}
{dob && <span className="pedigree-card__year">* {dob}</span>} {dob && <span className="pedigree-card__year">* {dob}</span>}
@@ -574,7 +574,13 @@ function PrintCell({
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>} {farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && ( {g.genotype && gen <= 2 && (
<div className="stammbaum-print__geno"> <div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))} {(() => {
try {
return displayGenotypeSafe(g.genotype)
} catch {
return g.genotype.replace(/\?/g, '-')
}
})()}
</div> </div>
)} )}
</div> </div>