FEAT: genotype parsing resilience and displayGenotypeSafe format helper
This commit is contained in:
@@ -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('')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user