fix(tickets): 16 Tickets der Züchterin — Genetik, Importer, Daten, Akte
All checks were successful
CI / Frontend Tests (Node/Vite) (push) Successful in 9m38s
CI / Backend Tests (.NET) (push) Successful in 1m14s
CI / Docker Build & Push (push) Successful in 1m33s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 8s

Genetik-Engine (bde4ec70, f89e95ad, 2322c2a8):
- Saphir/Platin/Platin-Hell unterscheiden sich NUR in der C-Zygotie und waren im
  Katalog identisch (locusToken reduziert auf das dominante Allel) → Saphir war
  unerreichbar. Token darf jetzt ein exaktes, ungeordnetes Allelpaar "x/y"
  verlangen: Platin C/C, Saphir C/cchm, Platin-Hell C/ch. TS- und Python-Mirror
  identisch, Seeds regeneriert, Migration ReseedColorVarietiesGen6Saphir.
- c[hm] wird als Alias auf c[chm] normalisiert (Tippfehler in zwei Charts) —
  vorher war Jays Gencode unparsebar und ergab "Zobel-Hell".
- 6 Tiere wechseln den errechneten Farbschlag (5x Platin→Saphir, Jay→Zobel).

Importer-Logik:
- renameTo-Notiz-Sweep: der alte Name blieb in Wurf-Notizen stehen ("Blacky +
  Kruke") — jetzt wortgenau ersetzt, mit vier Guards gegen Kollateralschaden.
- Neuer Resolution-Schlüssel goHomeDate (autoritativ, zieht GivenAway nach) und
  spottingType; Gencode-Overrides gewinnen jetzt bei präzisem Match (vorher
  wurden Entscheidungen stumm verschluckt, z. B. Eliza und Chris).
- parse_date verwirft implausible Jahre (JackJack hatte 1310-05-13).
- extract.py erkennt Stammbaum-Blöcke auch ohne Stern vor dem Geburtsdatum
  (Bijou bekam dadurch den Nachbar-Ast als Mutter).
- Backend-Ingest überträgt SpottingType (nur bei Payload-Wert).

Daten (conflict-decisions.json, re-ingest-stabil): Kuke-Merge + Zuchtname,
Jamie- und Sakura-Dubletten, Phantom-Tier "Unbekannt", Eltern von Kathlin,
Fast Boy/Ziwa und dem Q4-/TS-Wurf, Eliza-Gencode, Merle-Abgabe, JackJack.

Frontend (24522f5f): Abgabedatum steht in der Tier-Akte jetzt direkt unter dem
Abnehmer und hängt nicht mehr am Status.

Tests: +25 Checks test_merge_resolve, +7 test_genotype, +8 genetics.test.ts,
neuer Extract- und Ingest-Test, e2e GOHOME-ROW. Alles grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:31:48 +02:00
parent bf074696b4
commit 5a6d1c2541
22 changed files with 2832 additions and 182 deletions

View File

@@ -179,7 +179,15 @@ export function seedDb(): MockDb {
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
// Ticket 24522f5f (JackJack): abgegeben UND später beim Abnehmer verstorben —
// Status Deceased, aber Abnehmer + Abgabedatum gesetzt. Die Akte muss beides zeigen.
{
...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'),
status: 'Deceased',
dateOfDeath: '2023-08-15',
receiverContactId: 'con-huber',
goHomeDate: '2022-01-15',
},
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt

View File

@@ -221,6 +221,26 @@ test('Detailseite: Zuchtpartner-Liste trennt mit Komma HINTEN am Namen (KV-COMMA
expect(text).toBe('Greta, Frieda')
})
test('Detailseite: Abgabedatum steht direkt unter dem Abnehmer, auch bei Verstorbenen (GOHOME-ROW)', async ({
page,
}) => {
skipUnlessMock()
// Ticket 24522f5f (JackJack): Rosa wurde abgegeben und starb später beim Abnehmer
// (Status „Verstorben", aber Abnehmer + Abgabedatum gesetzt). Früher hing die
// Abgabedatum-Zeile am Status „Abgegeben" → sie fehlte genau in diesem Fall.
await page.goto('/rennmaeuse/rosa')
const receiverRow = page.locator('.ak-kv', {
has: page.locator('.ak-kv-label', { hasText: t.fields.receiver }),
})
await expect(receiverRow).toBeVisible()
await expect(receiverRow.locator('.ak-kv-value')).toContainText('Familie Huber')
// Abgabedatum ist die direkt folgende Zeile (Geschwister-Selektor).
const goHomeRow = receiverRow.locator('xpath=following-sibling::*[1]')
await expect(goHomeRow.locator('.ak-kv-label')).toHaveText(t.fields.goHomeDate)
await expect(goHomeRow.locator('.ak-kv-value')).toHaveText('15.01.2022')
})
/** QOL-ELTERN: Vater/Mutter im Abstammungs-Block des Tier-Formulars setzen. */
async function pickParent(page: Page, legend: string, name: string) {
const group = page.getByRole('group', { name: legend })

View File

@@ -913,3 +913,68 @@ describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)',
expect(res.inferred).toEqual([])
})
})
describe('GEN-6: C-Locus-Zygotie Platin / Saphir / Platin-Hell (bde4ec70 / f89e95ad)', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
// Züchterin autoritativ (Ticket f89e95ad): „Saphir ist aa Cc[chm] D- E- G- pp und
// Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!" Vorher trugen Platin,
// Saphir und Platin-Hell identische Katalog-Tokens (C:'C') → first-match-wins
// lieferte immer Platin.
it('aa CC DD EE GG pp -> Platin (zwei volle C)', () => {
expect(name('aa CC DD EE GG pp spsp rere')).toBe('Platin')
expect(name('aa CC DD EE GG pp Spsp')).toBe('Platin Schecke')
})
it('aa Cc[chm] DD EE GG pp -> Saphir (ein c[chm] daneben)', () => {
expect(name('aa Cc[chm] DD EE GG pp spsp rere')).toBe('Saphir')
})
it('JackJack (b6d8b3ef): aa Cc[chm] DD E- G- pp Spsp -> Saphir Schecke, NICHT Platin Schecke', () => {
expect(name('aa Cc[chm] DD E- G- pp Spsp')).toBe('Saphir Schecke')
})
it('aa Cc[h] DD EE GG pp -> Platin-Hell (GEN-3g: „-Hell" == c[h]-Allel)', () => {
expect(name('aa Cc[h] DD EE GG pp spsp rere')).toBe('Platin-Hell')
})
it('die drei Katalog-Einträge bilden ihren eigenen Namen zurück (kein Synonym-Kollaps)', () => {
for (const varietyName of ['Platin', 'Saphir', 'Platin-Hell']) {
const entry = BASE_COLORS.find((e) => e.name === varietyName)!
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(varietyName)
}
})
it('Seed-Kompaktnotation „aa Ccchm …" (so schreibt der Import JackSack) -> Saphir Schecke', () => {
// merge_and_resolve übernimmt bei Farb-LABEL „Saphir-Sp" ohne Gencode den
// CanonicalGenotype aus colorVarietySeed.backend.json (frozen symbols).
// Seit GEN-6 ist das die HET-Form — vorher „aa CC …" (= Platin-Gencode).
expect(name('aa Ccchm DD EE GG pp Spsp rere')).toBe('Saphir Schecke')
expect(name('aa Cch DD EE GG pp spsp rere')).toBe('Platin-Hell')
})
it('A-Wurf 18.02.2010 (Blacky × Kuke) erwartet Saphir UND Saphir Schecke', () => {
const blacky = fromDisplayString('aa Cc[chm] DD EE GG Pp spsp') // Vater
const kuke = fromDisplayString('aa CC DD Ee gg Pp Spsp') // Mutter (rpro3: Anthrazit-Schecke)
const names = breed(blacky, kuke).byFarbschlag.map((f) => f.farbschlag)
expect(names).toContain('Saphir')
expect(names).toContain('Saphir Schecke')
expect(names).not.toContain('Unbekannter Farbschlag')
})
})
describe('Schreibfehler-Alias c[hm] == c[chm] (Ticket 2322c2a8, Jay *24.10.2021)', () => {
it('c[hm] parst als c[chm] und ergibt Zobel, nicht Zobel-Hell', () => {
// Die Quell-Charts (Alberto Kids / Pukas Kids) schreiben „aa c[chm]c[hm] …" —
// dem zweiten Allel fehlt das c. Ohne Alias war der Genotyp unparsebar bzw.
// (im Python-Mirror) ein fremdes Allel → „Zobel-Hell".
const g = fromDisplayString('aa c[chm]c[hm] Dd Ee gg P- spsp')
expect(g.C).toEqual(['cchm', 'cchm'])
expect(genotypeToFarbschlag(g)).toBe('Zobel')
})
it('Cc[hm] bleibt ein c[chm]-Träger (relevant für die Probeverpaarung)', () => {
expect(fromDisplayString('aa Cc[hm] DD EE GG pp spsp').C).toEqual(['C', 'cchm'])
expect(genotypeToFarbschlag(fromDisplayString('aa Cc[hm] DD EE GG pp spsp'))).toBe('Saphir')
})
})

View File

@@ -64,7 +64,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Algierfuchs', english: 'Dark-Eyed Honey', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs.jpg' },
{ name: 'Blau', english: 'Blue', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'blau-schwarz-dd.JPG' },
{ name: 'Gold', english: 'Argente Golden', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold.jpg' },
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C/C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
@@ -78,12 +78,12 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
{ name: 'Saphir', tokens: { A: 'a', C: 'C/cchm', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C/ch', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
@@ -182,10 +182,33 @@ function locusToken(g: Genotype, locus: LocusKey): string {
return dominantAllele(locus, x, y)
}
/**
* GEN-6 (Tickets bde4ec70 / f89e95ad): ein Katalog-Token darf ein EXAKTES,
* ungeordnetes Allelpaar "x/y" verlangen statt des exprimierten (dominanten)
* Allels. Nötig, weil sich Platin / Saphir / Platin-Hell NUR in der C-Zygotie
* unterscheiden — die Züchterin autoritativ: "Saphir ist aa Cc[chm] D- E- G- pp
* und Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!":
* aa CC D- E- G- pp -> Platin
* aa C/c[chm] D- E- G- pp -> Saphir
* aa C/c[h] D- E- G- pp -> Platin-Hell (GEN-3g: "-Hell" == c[h]-Allel)
* Vorher trugen die drei Einträge identische Tokens (C:'C'), weil locusToken()
* den C-Locus auf das dominante Allel reduziert → "first match wins" lieferte
* immer Platin, Saphir/Platin-Hell waren unerreichbar. Die "x/y"-Konvention
* existierte schon für die het-Colourpoints (Siam/Zobel-Hell, C:'cchm/ch') und
* wird von representativeGenotype() bereits verstanden (GEN-3f) — deshalb bleibt
* die Katalog-REIHENFOLGE (und damit sortOrder + ColorVariety-GUIDs) unberührt.
*/
function pairMatches(g: Genotype, locus: LocusKey, token: string): boolean {
const [a, b] = resolvedPair(g, locus)
const [x, y] = token.split('/') as [string, string]
return (a === x && b === y) || (a === y && b === x)
}
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
return (Object.keys(entry.tokens) as LocusKey[]).every(
(locus) => locusToken(g, locus) === entry.tokens[locus],
)
return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => {
const token = entry.tokens[locus]!
return token.includes('/') ? pairMatches(g, locus, token) : locusToken(g, locus) === token
})
}
/**

View File

@@ -162,7 +162,7 @@
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa Ccchm DD EE GG pp spsp rere",
"sortOrder": 24,
"image": "saphir.jpg"
},
@@ -180,7 +180,7 @@
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"canonicalGenotype": "aa Cch DD EE GG pp spsp rere",
"sortOrder": 27,
"image": "platin-hell.jpg"
},

View File

@@ -162,7 +162,7 @@
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa Cc[chm] DD EE GG pp spsp",
"sortOrder": 24,
"image": "saphir.jpg"
},
@@ -180,7 +180,7 @@
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa Cc[h] DD EE GG pp spsp",
"sortOrder": 27,
"image": "platin-hell.jpg"
},

View File

@@ -220,6 +220,11 @@ function normalizeToken(tok: string): string | null {
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
// Ticket 2322c2a8: 'c[hm]' ist ein verschriebenes 'c[chm]' (fehlendes c) — so
// steht es in den Quell-Charts (Alberto Kids / Pukas Kids). Ohne dieses Alias
// wirft splitToken auf dem Token und der Genotyp ist in UI/Probeverpaarung
// unbrauchbar; mit Alias rechnet der TS-Mirror wie tools/import/genotype.py.
t = t.replace(/c\[hm\]/g, 'cchm') // Schreibfehler-Alias → Colourpoint-Allel
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook

View File

@@ -490,9 +490,6 @@ export default function GerbilDetailPage() {
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
</>
)}
{g.status === 'GivenAway' && (
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
)}
<Kv label={t.fields.colorVariety}>
{storedColorName ? (
<>
@@ -549,8 +546,14 @@ export default function GerbilDetailPage() {
g.originBreeder || '—'
)}
</Kv>
{(g.receiverContactId || g.status !== 'Deceased') && (
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
{/* Ticket 24522f5f (JackJack): Das Abgabedatum gehört direkt unter den Abnehmer und
darf NICHT am Status hängen — Tiere, die abgegeben wurden und später beim
Abnehmer starben (status=Deceased), haben Abnehmer UND Abgabedatum. */}
{(g.receiverContactId || g.goHomeDate || g.status !== 'Deceased') && (
<>
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
</>
)}
{g.isResident !== false && (partners.data?.items.length ?? 0) > 0 && (
<Kv label={t.detail.zuchtpartner}>