diff --git a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts index 1a40766..5755a7b 100644 --- a/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts +++ b/gerbil-manager-web/src/genetics/__tests__/genetics.test.ts @@ -16,6 +16,7 @@ import { fromJSON, wildType, extractGenotypeFlags, + displayGenotypeSafe, } from '../genotype' import { combineLocus } from '../punnett' 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(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('') + }) }) diff --git a/gerbil-manager-web/src/genetics/genotype.ts b/gerbil-manager-web/src/genetics/genotype.ts index 0d5df6d..eb98135 100644 --- a/gerbil-manager-web/src/genetics/genotype.ts +++ b/gerbil-manager-web/src/genetics/genotype.ts @@ -192,6 +192,11 @@ function normalizeToken(tok: string): string | null { // 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])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 // GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'. t = t.replace(/-/g, '?') @@ -257,6 +262,25 @@ export function fromDisplayString(input: string): Genotype { 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 { return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD) } diff --git a/gerbil-manager-web/src/genetics/index.ts b/gerbil-manager-web/src/genetics/index.ts index bff8454..d439aef 100644 --- a/gerbil-manager-web/src/genetics/index.ts +++ b/gerbil-manager-web/src/genetics/index.ts @@ -20,6 +20,7 @@ export { toJSON, fromJSON, hasUnknown, + displayGenotypeSafe, WILDCARD, } from './genotype' export type { Genotype, AllelePair } from './genotype' diff --git a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx index 898baf3..c529885 100644 --- a/gerbil-manager-web/src/pages/GerbilDetailPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilDetailPage.tsx @@ -6,7 +6,7 @@ import { listLitters as listLittersPaged } from '../api/litters' import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups' import { useApi, useMutation } from '../hooks/useApi' 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 Charakterbogen from '../components/Charakterbogen' // FEAT-6 (Oscar): Tab-Inhalte + Profilfoto @@ -22,10 +22,10 @@ function describeGenotype(genotype: string | null): { display: string; farbschla if (!genotype || !genotype.trim()) return null try { const g = fromDisplayString(genotype) - return { display: toDisplayString(g), farbschlag: genotypeToFarbschlag(g) } + return { display: displayGenotypeSafe(genotype), farbschlag: genotypeToFarbschlag(g) } } catch { - // Unparseable: show the raw value, no derived Farbschlag. - return { display: genotype, farbschlag: de.genetics.unknownFarbschlag } + // Unparseable: show the safely-rendered raw value, no derived Farbschlag. + return { display: displayGenotypeSafe(genotype), farbschlag: de.genetics.unknownFarbschlag } } } diff --git a/gerbil-manager-web/src/pages/StammbaumPage.tsx b/gerbil-manager-web/src/pages/StammbaumPage.tsx index 8a3b994..21f0547 100644 --- a/gerbil-manager-web/src/pages/StammbaumPage.tsx +++ b/gerbil-manager-web/src/pages/StammbaumPage.tsx @@ -29,7 +29,7 @@ import type { Gender, Gerbil, Litter } from '../api/types' import { useApi } from '../hooks/useApi' import { formatDate, genderLabel } from '../format/labels' import GerbilIcon from '../components/GerbilIcon' -import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' +import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics' import { DEFAULT_GENERATIONS, ancestorsAt, @@ -424,7 +424,7 @@ function PedigreeCard({ style={chip ? { background: chip.bg, color: chip.fg } : undefined} title={farbschlag} > - {g.genotype || farbschlag} + {g.genotype ? displayGenotypeSafe(g.genotype) : farbschlag} )} {dob && * {dob}} @@ -574,7 +574,13 @@ function PrintCell({ {farbschlag &&
{farbschlag}
} {g.genotype && gen <= 2 && (
- {toDisplayString(fromDisplayString(g.genotype))} + {(() => { + try { + return displayGenotypeSafe(g.genotype) + } catch { + return g.genotype.replace(/\?/g, '-') + } + })()}
)}