Compare commits

..

48 Commits

Author SHA1 Message Date
54ffb809c4 CHARAKTERBOGEN-2 (E1): Trait-Katalog in 4 Kategorien + 10 neue Traits + Warnsignale
- de.ts: traits-Array -> traitCategories (4 Kategorien: Sozialverhalten / Eignung &
  Umgang / Hobbys & Eigenarten / Wesen & Temperament); alle 15 bestehenden Keys
  erhalten (stored auf Live-Tieren); 10 neue Keys additiv; warn:true auf
  schwer-vergesellschaftbar + territorial.
- format/traits.ts: TraitEntry/TraitCategory-Interface, TRAIT_CATEGORIES-Export,
  ALL_TRAITS via flatMap, neuer isWarnTrait()-Helper.
- Charakterbogen.tsx: grouped-Rendering nach Kategorie (section + h4); warn-Chips
  mit trait-chip--warn-Klasse + trait-warn-badge.
- charakterbogen.css: .trait-category__heading + .trait-chip--warn (amber) +
  .trait-warn-badge.
- format/__tests__/traits.test.ts: 14 neue Vitest-Tests (Kategorien, Keys, warn,
  traitLabel/traitLabels).
- Gate: vitest 122/122, tsc clean, build clean, eslint clean.
2026-06-06 22:32:16 +02:00
643f1a6831 Merge feature/catalog-generator (AR-5): committed Katalog-Generator (gen:catalog) + frozen colorVarietySeed.backend.json + Drift-Guard
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
Frontend-only (Generator .mts + Seed-Artefakte + catalog-drift test). Gate: vitest 109/109.
Backend-Artefakt (70 Eintraege, GEN-4/4b-final) = Quelle fuer die Backend-ColorVariety-Re-Seed-Migration (Pam) vor dem WIPE+REIMPORT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:20:00 +02:00
27a3eb802c Merge feature/feat-8d-docx (FEAT-8d Phase 2): Wurfchronik-docx-Importer (Abnehmer/GoHomeDate/Tod)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
extract_docx.py (stdlib, 93 Wuerfe/227 Tiere) + ImportDocxService (fill-NULL-only: WS-Code→LitterId,
Abnehmer→ReceiverContactId, ABD→GoHomeDate, Tod→DateOfDeath+Ursache) + /import/docx/dry-run|execute.
157/157 C# + 28/28 python, has-pending=No. Dormant bis execute (NACHZUG nach Re-Import).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:05:35 +02:00
13a887608c docs(HUMANQUESTION): D7 — 13 neue Konflikt-Tiere aus den 41 Stammbaeumen (Sterbedatum + Gencode-Picks)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:04:25 +02:00
f74af537e2 FEAT-8d: docx-Importer fuer Wurfchronik-Detail (extract_docx.py + ImportDocxService)
tools/import/extract_docx.py (stdlib-Python, kein pip):
  Parst 'Wurfchronik der Kleinen Chaoten im Detail.docx' (Word/XML via zipfile).
  93 Wuerfe + 227 benannte Tiere aus Tabellen extrahiert.
  Felder pro Tier: WS-Code, Wurfgeburtsdatum, Name, Farbschlag, Geschlecht (Stern-
  Suffix), Abnehmer, Abgabedatum, Tod-Datum + Ursache, Partnername + DOB.
  Sonderwerte (ZT/BLEIBT/FREI/VG:) werden herausgefiltert.
  Edge-Cases: Doppel-Datum (16./17.03.2021, 31.05/*01.06.2023), WS ohne Zaehler
  (/5), fehlende Leerzeichen vor WS:, mehrere Abnehmer (1.) ... 2.) ...).
  Output: output/docx_litters.json + output/docx_animals.json.

tools/import/test_extract_docx.py:
  Unit-Tests fuer Regex-Logik + Live-Tests gegen die echte docx (skip wenn fehlt).
  28/28 Tests gruen.

GerbilManagerWebAPI/Import/ImportDocxService.cs:
  Idempotenter NACHZUG-Loader (fill-NULL-only, nie ueberschreiben):
  - WS-Code + Wurfgeburtsdatum -> PairingCode -> Gerbil.LitterId
  - Abnehmer -> Contact lookup-or-create -> Gerbil.ReceiverContactId
  - Abgabedatum -> Gerbil.GoHomeDate
  - Tod-Datum + Ursache -> Gerbil.DateOfDeath + CauseOfDeath
  Dry-Run zaehlt geplante Aenderungen, Execute schreibt.

GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs:
  POST /import/docx/dry-run + /import/docx/execute (analog ImportEndpoints).

GATE: 157/157 C#, 28/28 Python-docx-Tests, ef has-pending=No.
NACHZUG: laueft NACH dem finalen WIPE+REIMPORT-3 (kein Impact auf aktuellen Pipeline).
2026-06-06 22:04:09 +02:00
bb832b7f95 docs(HUMANQUESTION): aufgeraeumt — nur noch offene Punkte (A3/A4/A5/B2 + REW-Feinschliff) + Erledigt-Archiv
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Alle C/D-Genetik+Konflikt-Fragen + A1/A2/B1/B3 erledigt → ins kompakte Archiv. Offen: Gmail/Domain/NAS-Zugaenge, Firewall, optionaler Feinschliff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:01:54 +02:00
3a75005f43 docs(HUMANQUESTION): C6 erledigt — alle 32 Konflikt-Tiere entschieden (D6 komplett)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:39 +02:00
f2a64ad66b D6: Hanami Sterbedatum = 12.12.2019 (Julian 2026-06-07) — LETZTER D6-Konflikt, D6 KOMPLETT
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
conflict-decisions.json jetzt 16 Eintraege, alle D-Konflikte entschieden. Re-Import nicht mehr auf D6 gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:02 +02:00
3706ba3ecd Merge feature/stammbaum-open (STAMMBAUM-OPEN): Klick auf Namen oeffnet Tier (/rennmaeuse/{id})
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
PedigreeCard-Name = <Link> mit stopPropagation; Re-Root bleibt auf Karten-Flaeche. Unbekannt ohne Link.
+e2e (href + Keyboard/Accessibility). vitest 108, e2e 162. Frontend-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:56:31 +02:00
c8ab6bf072 STAMMBAUM-OPEN: Name in Ahnen-Karte ist Link zur Tier-Detailseite
- PedigreeCard: <span className=nametext> → <Link to=/rennmaeuse/{id}> mit e.stopPropagation() (Re-Root bleibt auf Karten-Bereich außerhalb des Namens)
- stammbaum.css: .pedigree-card__nametext color:inherit + text-decoration:none; hover→underline
- Kein Link im Drucklayout (PrintPedigree unverändert)
- e2e: href=/rennmaeuse/fridolin verifiziert + Keyboard-Navigation (fokus+Enter) öffnet Detailseite

Gate: vitest 108/108, e2e 162/162, tsc clean
2026-06-06 21:54:33 +02:00
30ee7b6198 AR-5: Seed-Artefakte auf finalem Katalog regeneriert (70 Eintraege, GEN-4/4b)
npm run gen:catalog lief auf rebased HEAD (post-GEN-4b, main 7fe538d).
Beide Artefakte aktualisiert:
- colorVarietySeed.generated.json: 70 Zeilen, Klammer-Notation (Display)
  Neue Namen: Dilute Gold/Platin/Agouti/Silberagouti/Kohlfuchs/Anthrazit/
  Topas/Blaufuchs + Dilute Fuchs-Varianten (Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs)
- colorVarietySeed.backend.json: 70 Zeilen, frozen symbols (ef/cchm/ch)
  Quelle fuer Pams Backend-Re-Seed-Migration (Guardrail: nie Klammern)
Drift-Guard (catalog-drift.test.ts) laeuft gruen.
Gate: build ✓  eslint ✓  vitest 109/109 ✓
2026-06-06 21:52:44 +02:00
29207e41da AR-5: Committed Katalog-Generator + Drift-Guard + Backend-Artefakt
- gen-seed.mts: deterministischer Generator aus catalog.ts (Single Source);
  emittiert BEIDE Artefakte: generated.json (Klammer-Notation, Display) +
  backend.json (frozen ef/cchm/ch, für Pam EF-Migrationen). Kein magisches
  Artefakt mehr.
- package.json: npm run gen:catalog (npx tsx gen-seed.mts)
- catalog-drift.test.ts: Drift-Guard — liest generated.json von Disk via
  import.meta.url + readFileSync, vergleicht mit live CATALOG; Fail-Meldung
  zeigt 'npm run gen:catalog'. 5 Test-Files, 93 Tests grün.
- colorVarietySeed.backend.json: 66 Zeilen frozen symbols (ef/cchm/ch),
  kein sofortiger Backend-Eingriff (AR-5 Guardrail; Pam konsumiert bei
  nächster Reseed-Migration).
- README.md: Katalog-Generator-Doku (wann laufen, was erzeugt wird).
Gate: build ✓  eslint ✓  vitest 93/93 ✓
2026-06-06 21:51:55 +02:00
7fe538dd5d Merge feature/gen-4 (GEN-4b): 7 Farbarten als Engine-Guard (keine Farbschläge) + Schimmel-Naming-Fix
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
ff42341: CATEGORY_NAMES-Guard (Standard/Colourpoint/Dilute/Fuchs/Fuchsschimmel/Schimmel/CP-Dilute nie als Ergebnis, FK=0);
locusToken E ef/e→'ef' (het-Schimmel matchen konkrete Katalog-Eintraege statt Family-Fallback 'Fuchsschimmel');
baseColourFor Family-Fallback→null; Orangeschimmel A-Restriktion (aa→Kohlfuchsschimmel). Frontend-only. vitest 96, e2e 148.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:49:20 +02:00
ff42341c47 GEN-4 Addendum: 7 Farbarten-Kategorien blockiert + locusToken ef/e-Fix
catalog.ts:
- locusToken E: ef/e -> 'ef' (phenotypisch ausgedrücktes Allel; enables
  catalog-Match fuer het Schimmel-Tiere wie Kohlfuchsschimmel/Orangeschimmel).
  Vorher: 'eef' matchte keinen Katalogeintrag -> Family-Fallback 'Fuchsschimmel'.
- baseColourFor: Family-Fallback entfernt (null statt family-Name) — Familie
  ist nur noch Katalog-Suchfilter, nie direkter Ausgabewert.
- CATEGORY_NAMES Guard in farbschlagFor: Fuchs/Fuchsschimmel/Schimmel/Standard/
  Colourpoint/Dilute/Colourpoint Dilute -> Unbekannter Farbschlag wenn Engine
  diese Namen zurueckgeben wuerde.
- Orangeschimmel: A:'A' hinzugefuegt (Agouti-Einschraenkung), damit aa-ef-Tiere
  durch zu Kohlfuchsschimmel fallen.
- FK-Check: Keine der 7 Kategorie-Namen sind in BASE_COLORS -> 0 FK-Risiko.

genetics.test.ts:
- 'eef with unknown other loci': erwartet jetzt 'Kohlfuchsschimmel' (korrekt!)
- GEN-4 Farbarten-Fixture: Kategorie-Guard-Test + FK-Check-Assert.
Gate: build ✓  eslint ✓  vitest 96/96 ✓  e2e 148/148 ✓
2026-06-06 21:42:15 +02:00
90f998d189 Merge feature/animal-litters (ANIMAL-LITTERS): 'Wuerfe als Elternteil' auf Tier-Detailseite
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilDetailPage: Sektion listet Wuerfe wo fatherId|motherId==Tier (Gridify OR-Filter),
je Link /wuerfe/{id} + Vater/Mutter-Badge + Datum + totalBorn; Leer-Zustand. Rueckweg Tier→Wuerfe.
Frontend-only. vitest 104, e2e 160.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:42:04 +02:00
d5f4be76be ANIMAL-LITTERS: Tier-Detailseite zeigt Würfe dieses Tiers als Elternteil
- GerbilDetailPage: listLittersPaged({filter: fatherId={id}|motherId={id}}) + neue Sektion 'Würfe als Elternteil' vor den Tabs; je Wurf: Link /wuerfe/{id}, Vater/Mutter-Badge, Datum, Wurfstärke; Leer-Zustand wenn keine Würfe
- de.ts: parentLittersTitle, parentLittersEmpty, parentLittersRoleVater/Mutter (append-only in detail-Sektion)
- e2e: +2 Tests (Fridolin hat Wurf K als Vater; Krümel hat Leer-Zustand)

Gate: vitest 104/104, e2e 160/160, tsc clean
2026-06-06 21:39:14 +02:00
433f9ee592 Merge feature/gen-4 (GEN-4): Farbschlag-Benennung (Dilute-Praefix, REW, Fuchs spezifisch) + BreedingView
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
- dd → 'Dilute '-Praefix (Agouti dd→Dilute Agouti etc.); 4 Dilute-Fuchs-Eintraege gegen nackten 'Fuchs'
- REW = beide C-Allele reduziert (kein volles C) + pp (alle 3 Kombis, A-unabhaengig)
- CP-Fuchs in berechneter Verteilung unterdrueckt
- BreedingResultView: Gencode (Klammer) pro Karte + Karten-Layout-Fix (overflow/responsive)
Frontend-only (engine+catalog.ts+BreedingResultView+index.css). vitest 95, e2e 148.
OFFEN als Follow-up: 7 Kategorie-Eintraege entfernen, PEW=REW-Konsolidierung, Backend-Re-Seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:34:17 +02:00
5c327cd8d8 GEN-4 REW erweitert: alle 3 C-Kombi + pp = REW (Julian bestätigt)
- cReduced(c) = cchm || ch; REW wenn cReduced(c0) && cReduced(c1) && pp
  (cchm/cchm, cchm/ch, ch/ch alle REW; mind. ein volles C = NICHT REW)
- Counterproof-Test: C/cchm + pp und C/ch + pp = nicht REW
- Frozen-Round-Trip-Test: PEW (ch/ch+pp) explizit ausgenommen (intentional
  shadow durch REW-Check per Julian-Regel; PEW bleibt Dropdown-Name)
Gate: build ✓  eslint ✓  vitest 95/95 ✓  e2e 148/148 ✓
2026-06-06 21:32:20 +02:00
b12b5d720d GEN-4: Dilute-Präfix, REW-Erkennung, CP-Fuchs-Fix, BreedingResultView Gencode+Layout
Katalog (catalog.ts):
- 8 'X dd'/'dd X' Einträge → 'Dilute X' (Agouti/Silberagouti/Kohlfuchs/Anthrazit/
  Topas/Blaufuchs dd + dd Gold/Platin → Dilute Gold/Platin etc.)
- 4 neue Dilute-Fuchs-Basiseinträge (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs)
  → verhindert naked 'Fuchs' Fallback für agouti+dilute+fox Genotypen. 66→70 Einträge.
- colourpointName: 'Dilute X' Base → 'Dilute CP-X' statt 'CP-Dilute X' (Präfix-Reihenfolge)
  → AA cchmcchm dd ee GG PP = 'Dilute CP-Algierfuchs' statt 'CP-Fuchs'.

Engine (farbschlagFor):
- REW-Check: c0==cchm && c1==cchm && p0==p && p1==p → 'REW' (Rotaugenweiß).
  Unabhängig von A/D/E/G. Flagged to god zur Bestätigung.

BreedingResultView:
- Gencode (bracket notation) pro Farbschlag-Karte (erster/wahrscheinlichster Genotyp).
- Layout-Fix: flexbox body (name+gencode gestapelt), min-width:0 gegen Overflow,
  word-break, grid min 15rem, prob flex-shrink:0 rechtsbündig.

Tests: GEN-4 describe (Dilute/REW/no-bare-Fuchs Fixtures); CATALOG_SIZE 66→70;
  CP-Fuchs/CP-Fuchs-Hell → Dilute CP-Algierfuchs(-Hell) in bestehenden Tests.
Gate: build ✓  eslint ✓  vitest 95/95 ✓  e2e 148/148 ✓
2026-06-06 21:27:32 +02:00
92dd614269 Merge feature/namegen-frontend (FEAT-NAMEGEN-FE UC-1): 'Name vorschlagen'-Button (Gemini, live)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
NameSuggestPanel + names.ts + GerbilForm-Button (Anfangsbuchstabe/Geschlecht/Kulturen → /names/suggest),
Vorschlag-Klick befuellt Namensfeld; 503 NamesKeyMissing-Hinweis. vitest 104, e2e 156. Frontend-only.
Namensgenerator UC-1 LIVE (Gemini-Key free-tier aktiv).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:19:29 +02:00
097a04cbd8 FEAT-NAMEGEN UC-1: 'Name vorschlagen'-Panel auf GerbilFormPage
- api/names.ts: suggestNames() + buildSuggestPath() + NAMEGEN_USAGES (norn/japa/mythg/ger/arb)
- de.ts: namegen-Sektion (button, panelTitle, letter/usages/load/states)
- NameSuggestPanel.tsx: inline-Panel mit Buchstabe/Herkunftskultur-Multiselect, Vorschlagsliste; 503 NamesKeyMissing → freundlicher Hinweis
- GerbilFormPage: 'Name vorschlagen'-Button neben Namensfeld (htmlFor-Pattern), Panel schließt+befüllt bei Auswahl
- vitest: 11 Tests (buildSuggestPath URL-Logik + NAMEGEN_USAGES-Struktur)
- e2e: mock /names/suggest + namesConfigured-Flag; 4 Specs (Panel öffnen, Klick befüllt, Buchstabe-Filter, 503-Fallback)

Gate: vitest 104/104, e2e 156/156, tsc clean
2026-06-06 21:17:42 +02:00
b7ebfd3057 Merge feature/namegen-backend (FEAT-NAMEGEN-BE): GET /names/suggest via Gemini + LitterLetter
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 57s
NameSuggestionService (bestehender OpenAiChatClient/Gemini, Fence-Strip, [{name,meaning,origin}]),
NamesEndpoints (503 NamesKeyMissing / 502 NamesUpstreamError), Litter.LitterLetter + Migration AddLitterLetter.
157/157, drift clean (Dwight). has-pending via CI (lokaler Build durch laufende App gesperrt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:16:40 +02:00
d268bbc122 D6: Kazu E=ee[f] G=GG P=PP (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 57s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Aa Cc[chm] DD ee[f] GG PP Spsp (UwUw-Quelle = GG). D6 nur noch Hanami offen (1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:59:29 +02:00
a638cf2c52 FEAT-NAMEGEN: GET /names/suggest backend (Gemini, 503 NamesKeyMissing, LitterLetter)
- NameSuggestionService: Gemini via existing OpenAiChatClient, builds
  letter/gender/usages/count prompt, strips markdown fences from response,
  deserialises [{name,meaning,origin}] array; NotConfigured -> 503.
- GET /names/suggest?letter=&gender=&usages=&count= -> Ok<List<NameSuggestion>>
  | 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}.
- Litter.LitterLetter (string?, nullable) + AddLitterLetter migration.
- 17 new tests (prompt assembly, fence strip, parse edge cases,
  503-not-configured, upstream-error); total 157/157 green.
- No Behind-the-Name dependency — Gemini path only (Julian's decision).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:56:33 +02:00
efbf24ca39 Merge feature/ci-backend-fix: dotnet-ef auf $GITHUB_PATH (Backend-CI-Job gruen) [god, via god-qa-Worktree]
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
2026-06-06 20:54:47 +02:00
22e033b26a CI-BACKEND-FIX: dotnet-ef PATH nach global tool install exportieren
dotnet tool update --global installiert dotnet-ef nach ~/.dotnet/tools,
aber der Runner-Container hat dieses Verzeichnis nicht im PATH. Jeder
run:-Step startet eine neue Shell -> der naechste Step (dotnet ef ...)
findet 'dotnet-ef' nicht (exitcode 1, Job rot seit DB-3).

Fix: 'echo ... >> $GITHUB_PATH' nach dem tool install; Gitea/GitHub Actions
liest diese Datei zwischen Steps und persistiert den PATH-Eintrag fuer alle
Folge-Steps in diesem Job.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:51:10 +02:00
ee7f0aeab4 docs(HUMANQUESTION): A1+A2 erledigt — Gemini-Key live, KOSTENLOS via gemini-flash-latest
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 49s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
KI-Verkaufstexte + Charakterbogen live verifiziert (echter Text generiert). Kein Billing noetig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:49:53 +02:00
454f6e0d60 fix(ai): Default-Modell gemini-2.0-flash -> gemini-flash-latest (Free-Tier-fähig)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
gemini-2.0-flash hat auf Julians Konto KEIN freies Kontingent (429), gemini-flash-latest schon
(direkt verifiziert). Julian bleibt kostenlos. compose/.env.example/docs angepasst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:48:05 +02:00
c652afbbc7 D6: Skarlett Sterbedatum = 17.04.2016 (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2018 war falsch (leakte als '/ +2018' in den Gencode; in IMPORT-POLISH gefixt). D6 noch offen: Hanami, Kazu (2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:30 +02:00
545db4132f D6: Vance Jr. Sp-Locus = spsp (ungescheckt, Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Failing after 52s
aa Cc[chm] Dd Ee gg P- spsp (c[hm]→c[chm] normalisiert). D6 noch offen: Hanami, Kazu, Skarlett (3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:44:24 +02:00
ddc4676f87 D6: Big Ben P-Locus = Pp (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION gestrichen
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Aa Cc[chm] DD Ee Gg Pp Spsp. Noch offen in D6: Hanami, Vance Jr., Kazu, Skarlett (4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:42:49 +02:00
083ffac47a chore(api): UserSecretsId fuer AI-Config (Gemini-Key via user-secrets, nie committed)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 49s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Nur die UserSecretsId-GUID — der Schluessel selbst liegt in %APPDATA% user-secrets, nicht im Repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:40:16 +02:00
b6178b630b fix(ci): dotnet-ef Tool-Install idempotent (tool update) — self-hosted Runner persistiert --global
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
'tool install --global' schlug ab dem 2. Lauf mit 'already installed' fehl (Tools persistieren auf
dem self-hosted Runner) → Backend-Job rot seit DB-3, noch VOR dem Drift-Check. 'tool update' ist
installiert-oder-aktualisiert = idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:03:18 +02:00
55d08a5dc3 fix(ci): DB-3 Drift-Check — -c Release war --context Release (DbContext-Name!), nicht Build-Config
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
dotnet ef interpretiert -c als --context, daher 'No DbContext named Release was found' →
Backend-Tests-Job rot seit DB-3. Korrekt: --configuration Release. Lokal verifiziert:
Release-Build + --no-build --configuration Release → 'No changes'. Tests 140/140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:58:52 +02:00
532cbf3bcf Merge feature/qa-smoke-2: Live-Smoke der heutigen Merges (18/18, keine Findings)
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Bestätigt live gegen echte Daten: GEN-3h Bracket-Notation (ee[f]/c[chm]), Herkunft+Gehoerlos editierbar,
CR-2 Partial-PUT behaelt Felder, namenlose Platzhalter, Gehege-Redirect. Finale Pre-Uebergabe-Bestaetigung.
e2e-only (skipUnlessLive). B3-Korpus-IDs vollstaendig dokumentiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:53:12 +02:00
d1baba686f QA-SMOKE-2: Live-Smoke GEN-3h/FORM-FIELDS/CR-2/UI-POLISH/GEHEGE (18/18 gruen) 2026-06-06 19:51:45 +02:00
8b867b9058 Merge feature/review-fixes-2: DB-4 deutsche ICU-Collation + DB-5 Litter.ExternalRef
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Failing after 51s
- DB-4: UseCollation('de-x-icu') auf Gerbil.Name/NameSearch/OriginBreeder, ColorVariety.Name, Contact.Name
  (Npgsql-konditional; SQLite-Testhost lehnt unbekannte Collation ab). Migration AddGermanCollation.
- DB-5: Litter.ExternalRef + filtered unique index + Importer-Idempotenz (ExternalRef primaer, Name+Date Fallback).
  Migration AddLitterExternalRef. +1 Regressionstest.
Gate 140/140, has-pending=No.
[god-QA: backend-only, disjunkt; 2 Migrationen]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:50:44 +02:00
ea3596ca0d REVIEW-FIXES-2: DB-4 (Deutsche Collation ä/ö/ü) + DB-5 (Litter.ExternalRef)
DB-4 (medium): ICU German collation de-x-icu auf deutschen Textspalten
  Gerbil.Name, Gerbil.NameSearch, Gerbil.OriginBreeder, ColorVariety.Name,
  Contact.Name. Korrekte ä/ö/ü-Sortierung + locale-aware lower() in Postgres.
  Nur bei Npgsql (Database.ProviderName check) — SQLite-Testhost kennt keine
  eigenen Collation-Namen und wuerde EnsureCreated fail lassen.
  Migration AddGermanCollation (ALTER COLUMN ... TYPE text COLLATE de-x-icu).

DB-5 (low): Litter.ExternalRef + filtered unique index + Importer-Anbindung
  Litter.ExternalRef (string?, nullable) = stable import source id (sl.Id).
  Filtered unique index WHERE ExternalRef IS NOT NULL (wie DB-1 fuer Gerbil).
  Migration AddLitterExternalRef.
  Importer (ImportService.cs): setzt ExternalRef = sl.Id beim Litter-Insert;
  prueft existingLitterExtRefSet als PRIMAEREN Idempotenz-Key (ExternalRef),
  Name+Date-Key als Fallback fuer Wuerfe ohne ExternalRef aus frueheren Laeufen.
  +Test DB5_Litter_ExternalRef_set_and_used_for_idempotency (mutiert den Namen
  nach erstem Import, prueft dass Re-Import via ExternalRef erkennt).

GATE: 140/140 C#, has-pending=No.
2026-06-06 19:49:50 +02:00
e6b513ed5d Merge feature/genotype-notation (GEN-3h): Genotyp-Anzeige in Zuechterin-Schreibweise
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
CI / Backend Tests (.NET) (push) Failing after 49s
- Bracket-Notation im Display: e[f]/c[chm]/c[h] (Storage bleibt frozen symbols)
- E-Locus-Reihenfolge: e VOR e[f] ('ee[f]' nicht 'efe')
- CR-1a: ee[-] (Silvain) Parser-Fix (lookbehind)
- C zeigt jetzt 'aa C- D- ee[f] Gg Pp spsp rere'
vitest 92/92 (inkl. ee[-] + C/Zuleika/Milka-Orakel), e2e 148, build/eslint. Frontend-only (src/genetics/).
HINWEIS: generated.json canonicalGenotype jetzt Klammer-Notation = DISPLAY; Backend-Seed bleibt frozen (Matching).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:27:45 +02:00
52303b90b2 CR-1a: ee[-] Parser-Fix (Silvain) — lookbehind trennt e[-] vom führenden Allel
normalizeToken: lookbehind-Regex /(?<=[A-Za-z])e\[-\]/→'?' greift wenn e[-]
von einem Buchstaben (erstes Allel) gefolgt wird (ee[-] → e?); standalone e[-]
fällt durch auf die generische \[-\]→? Regel (→ e?). Beide Pfade liefern
splitToken 2 Allele [e,?]. Vorher: ee[-]→ee?→ 3 Allele → Fehler.
Fixture: Silvain 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp' → E=[e,?] ✓.
Gate: build ✓  eslint ✓  vitest 92/92 ✓  e2e 126/126 ✓
2026-06-06 18:24:06 +02:00
4b6a07544d GEN-3h: Breeder bracket-notation display + E-locus e-vor-e[f] Sortierung
- genotype.ts: toDisplayString zeigt ef→e[f], cchm→c[chm], ch→c[h] (Display-only;
  Storage-Contract unverändert). E-Locus Display-Rank E>e>ef: {ef,e} Paar
  rendert als ee[f] statt e[f]e (Züchterin-Konvention, Julian-Feedback).
- genotype.ts: normalizeToken akzeptiert Klammer-Eingabe (e[f], c[chm], c[h],
  [-]) → interne Symbole; vollständiger Round-Trip Display→Parse.
- genetics.test.ts: Katalog-Regex auf [A-Za-z[\]?-]+ erweitert; GEN-3h
  Notation-Fixtures (C/Zuleika/Milka-Orakel, Klammer-Round-Trip, E-Sortierung).
- colorVarietySeed.generated.json: neu generiert (66 Zeilen mit Klammer-Notation).
Gate: build ✓  eslint ✓  vitest 91/91 ✓  e2e 120/120 ✓
2026-06-06 18:24:06 +02:00
8f90821081 Merge feature/ci-drift-check (DB-3): CI EF-Migrations-Drift-Check
Some checks failed
CI / Backend Tests (.NET) (push) Failing after 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Has been skipped
.gitea/workflows/ci.yml: nach Build zwei Steps (dotnet-ef install + has-pending-model-changes)
→ faengt Snapshot/Migrations-Drift im Runner (EnsureCreated-SQLite-Testhost sieht das nie).
Automatisiert die manuelle MERGE-GATE-Regel. Nur CI-Config, kein Code/Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:06:07 +02:00
9dda78b28a DB-3: EF Migrations Drift-Check in CI (has-pending-model-changes)
Nach dem Build-Schritt in test-backend: dotnet-ef 10.0.* global installieren,
dann `dotnet ef migrations has-pending-model-changes` ausführen. Schlaegt fehl
wenn Code-Änderungen an Entities/OnModelCreating keine passende Migration haben.
Fängt genau den Drift, den der SQLite-EnsureCreated-Testhost nicht sieht.
2026-06-06 18:05:15 +02:00
865b3831c8 Merge feature/ops-fixes: DataProtection-Key-Persistenz (AR-3) + prod-compose AI-Env (AR-4)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- AR-3: AddDataProtection().PersistKeysToFileSystem + compose keys-Volume → Gmail-App-Passwort
  ueberlebt Redeploy (war ephemer → Inbox waere still gebrochen). Dev-Fallback .data-protection-keys.
- AR-4: prod compose.yaml ANTHROPIC_API_KEY → AI__BaseUrl/ApiKey/Model + .env.example aktualisiert.
139/139, has-pending=No, kein Schema-Change. [god-QA: config/Program.cs only, disjunkt]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:03:20 +02:00
1b0d3286db OPS-FIXES-1: AR-3 Data Protection Key-Persistenz + AR-4 AI-Env-Korrekte
AR-3 (P1): PersistKeysToFileSystem + persistentes Volume
  Program.cs: AddDataProtection().PersistKeysToFileSystem(keyRingPath).SetApplicationName(GerbilManager)
  Pfad konfigurierbar via DataProtection:KeyRingPath (env DataProtection__KeyRingPath);
  Fallback = ContentRoot/.data-protection-keys (Aspire-Dev-ephemeral, ok).
  compose.yaml: DataProtection__KeyRingPath: /data/keys + Volume-Mount keys:/data/keys.
  Volumes: neues 'keys' Volume (Bind-Mount auf NAS-Dataset KEYS_PATH=/mnt/SSD/gerbil/keys).
  .gitignore: .data-protection-keys/ ignoriert (Dev-only ephemeral keys).
  Verhindert: Gmail-App-Passwort wird nach Image-Redeploy unlesbar (bisher stilles inbox-fail).

AR-4 (P1): compose.yaml + .env.example: AI__* statt ANTHROPIC_API_KEY
  compose.yaml: ANTHROPIC_API_KEY entfernt (Code liest es nicht). Korrekte Vars:
    AI__BaseUrl: ${AI__BaseUrl:-}
    AI__ApiKey: ${AI__ApiKey:-}
    AI__Model: ${AI__Model:-gemini-2.0-flash}
  .env.example: AI__BaseUrl/ApiKey/Model + KEYS_PATH hinzugefuegt; ANTHROPIC_API_KEY entfernt.
  Quelle: docs/ai-provider.md (war korrekt, compose war falsch).
  Verhindert: alle 4 KI-Features (Verkaufstext, Inbox-Entwurf) blieben in prod stumm.

GATE: 139/139 C#-Tests, build gruen (using Microsoft.AspNetCore.DataProtection; framework-included).
2026-06-06 18:01:57 +02:00
de1d9460b3 Merge feature/review-fixes-backend: CR-9/CR-10/CR-11 + DB-1 (ExternalRef unique)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 56s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- CR-9: Import-Lookup TryGetValue + ExternalRef-Fallback (kein throw bei name/DOB-Drift)
- CR-11: Farbschlag aus Genotyp ableiten (fill-NULL-only, nur vollstaendige Genotypen ≥8/kein ?? → keine False-Positives); Plan-Loop + Post-Sweep fuer Bestandstiere
- CR-10: conflict-decision validiert Genotyp-Override (ungueltig → skip+Warnung, Konflikt trotzdem geloest)
- DB-1: filtered unique index ExternalRef IS NOT NULL + Migration UniqueExternalRef
Gate 139/139 C# + python all pass, ef has-pending=No. KEIN Farbschlag-Overwrite (fill-NULL).
[god-QA: disjunkt zu frontend-Merges]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:55:55 +02:00
259dd96c0e Merge feature/ui-polish-2: '(ohne Namen)'-Platzhalter in Stammbaum + VertragWizard + WurfForm
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Namenlose Stub-Tiere zeigen jetzt ueberall '(ohne Namen)' statt leer: Flagship-Stammbaum
(Karten+Druck+Header), Vertrags-Assistent, Wurf-Formular. +2 e2e (namenloser Ahne im Stammbaum).
vitest 82, e2e 148, build clean. Kein EF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:54:33 +02:00
615abfa510 REVIEW-FIXES-BACKEND: CR-9 + CR-11 + CR-10 + DB-1
CR-9 (major): gidByNameDob TryGetValue + ExternalRef-Fallback
  Verhindert KeyNotFoundException wenn Name/DOB zwischen zwei Laeufen driftet
  (z.B. correctDob-Remap oder UI-Umbenennung). Fallback: ExternalRef-Dict-Lookup;
  bei Miss: sauberes Ueberspringen + Note statt 500. +Test CR9_NameDOB_drift.

CR-11 (major): Farbschlag aus Genotyp ableiten (fill-NULL-only)
  Deep-Band-Tiere (gen>=2, kein Farbschlag-Feld) landen nicht laenger mit null
  ColorVariety. GenotypePotentiallyMatches() vergleicht locus-pair-weise (??=wildcard,
  case-insensitive). Nur vollstaendige Genotypen (8 Loci, kein ??) loesen Ableitung aus.
  Plan-Loop: fuellt colorVarietyId bei null + vollstaendigem Genotyp.
  Post-Sweep: bestehende DB-Tiere mit null ColorVarietyId werden nachgefuellt.
  AnimalSummary.FarbschlagDerivedFromGenotype = Zaehler. +Test CR11_ColorVariety_from_geno.

CR-10 (major, Python): malformed Override-Genotyp wird nicht angewendet
  apply_conflict_decisions validiert mapped8locus nach gt.parse(). Leeres Ergebnis
  = Genotyp unveraendert + decisionWarning statt stillem Blanken. Konflikt wird
  trotzdem aufgeloest (Entscheidung gilt, nur Genotyp-Override ausgelassen).
  +5 Python-Tests (CR-10-Block in test_extract.py).

DB-1 (high): filtered unique index auf Gerbil.ExternalRef
  WHERE ExternalRef IS NOT NULL — verhindert doppelten Import bei Race-Conditions
  oder Lauf-Ueberschneidungen. Migration UniqueExternalRef. SQLite-Testhost:
  HasFilter() wird via EnsureCreated appliziert (SQLite unterstuetzt Partial-Indexes).
  PartialUpdateTests externalRef-Assertion auf NotNull geaendert (name-hash unique).

GATE: 139/139 C# + Python ALL PASS, ef has-pending=No.
2026-06-06 17:54:06 +02:00
64 changed files with 9291 additions and 402 deletions

View File

@@ -48,6 +48,26 @@ jobs:
- name: Build - name: Build
run: dotnet build GerbilManager.slnx --no-restore -c Release run: dotnet build GerbilManager.slnx --no-restore -c Release
- name: dotnet-ef Tool installieren
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
run: |
dotnet tool update --global dotnet-ef --version 10.0.*
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: DB-3 EF Migrations Drift-Check
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
# entities/OnModelCreating without generating a matching migration). Catches exactly
# the class of drift the SQLite/EnsureCreated test host is blind to.
run: >
dotnet ef migrations has-pending-model-changes
--project GerbilManagerWebAPI
--startup-project GerbilManagerWebAPI
--no-build --configuration Release
- name: Tests ausfuehren - name: Tests ausfuehren
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal" run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"

3
.gitignore vendored
View File

@@ -134,3 +134,6 @@ entities.json
# Runtime photo store (uploaded/imported gerbil photos) — never commit # Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/ GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/

View File

@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
Assert.Equal(2, await db.Litters.CountAsync()); Assert.Equal(2, await db.Litters.CountAsync());
} }
[Fact]
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
{
// DB-5: litters must have ExternalRef set to the source litter id on first import,
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
using var db = NewDb();
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
// ExternalRef is set on created litters
var litters = await db.Litters.ToListAsync();
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
Assert.Contains(litters, l => l.ExternalRef == "L1");
Assert.Contains(litters, l => l.ExternalRef == "L2");
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
foreach (var l in litters) l.Name = "Geänderter Name";
await db.SaveChangesAsync();
// Re-import: litters detected as existing via ExternalRef even though Name changed
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Litters.Created);
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact] [Fact]
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags() public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
{ {
@@ -580,6 +605,74 @@ namespace GerbilManager.Tests
Assert.Equal(new DateOnly(y, m, d), date); Assert.Equal(new DateOnly(y, m, d), date);
} }
[Fact]
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
{
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
// stable ExternalRef without throwing.
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
// Run 1: load the animal normally
await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, await db.Gerbils.CountAsync());
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
g.Name = "Umbenannt Tier";
await db.SaveChangesAsync();
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
{
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
// Exact Agouti genotype, no explicit Farbschlag name
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
// ColorVarietyId must be set even though no explicit Farbschlag name was given
Assert.NotNull(tier.ColorVarietyId);
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
Assert.Equal("Agouti", variety!.Name);
// Report counter should reflect the genotype derivation
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
// ---- fixtures ---- // ---- fixtures ----
private const string LittersJson = """ private const string LittersJson = """
[ [

View File

@@ -0,0 +1,208 @@
using System.Net;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Names;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
/// fence strip), 503-not-configured path, upstream-error path.
/// </summary>
public class NameSuggestionTests
{
// ── Prompt assembly ───────────────────────────────────────────────────
[Fact]
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("reinen JSON-Array", prompt);
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
Assert.Contains("name", prompt);
Assert.Contains("meaning", prompt);
Assert.Contains("origin", prompt);
}
[Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt);
Assert.Contains("norn,mythg", prompt);
}
[Fact]
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
Assert.Contains("5", prompt);
Assert.DoesNotContain("Buchstaben", prompt);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
[Fact]
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
// ── JSON parsing ──────────────────────────────────────────────────────
[Fact]
public void ParseSuggestions_verarbeitet_reines_JSON()
{
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
var result = NameSuggestionService.ParseSuggestions(json);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Astrid", result[0].Name);
Assert.Equal("göttliche Stärke", result[0].Meaning);
Assert.Equal("Altnordisch", result[0].Origin);
}
[Fact]
public void ParseSuggestions_strippt_json_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Aiko", result![0].Name);
}
[Fact]
public void ParseSuggestions_strippt_generische_Markdown_Fence()
{
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Luna", result![0].Name);
}
[Fact]
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
{
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
var result = NameSuggestionService.ParseSuggestions(messy);
Assert.NotNull(result);
Assert.Equal("Sol", result![0].Name);
}
[Fact]
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
{
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
Assert.Null(NameSuggestionService.ParseSuggestions(""));
}
// ── 503: nicht konfiguriert ───────────────────────────────────────────
[Theory]
[InlineData(null, "key", "model")]
[InlineData("https://api.example.com/v1", null, "model")]
[InlineData("https://api.example.com/v1", "key", null)]
[InlineData(null, null, null)]
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
string? baseUrl, string? apiKey, string? model)
{
var service = CreateService(baseUrl, apiKey, model,
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
var result = await service.SuggestAsync(null, null, null, 5);
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
Assert.Null(result.Suggestions);
}
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
[Fact]
public async Task SuggestAsync_parst_valide_JSON_Antwort()
{
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal(2, result.Suggestions!.Count);
Assert.Equal("Astrid", result.Suggestions[0].Name);
}
[Fact]
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var handler = new StubHandler(_ => Canned(fenced));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.Equal("Luna", result.Suggestions![0].Name);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
{
var handler = new StubHandler(_ => Canned("das ist kein json"));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
Assert.Null(result.Suggestions);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
}
// ── Helfer ────────────────────────────────────────────────────────────
private static NameSuggestionService CreateService(
string? baseUrl, string? apiKey, string? model, StubHandler handler)
{
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
return new NameSuggestionService(new HttpClient(handler), options);
}
private static HttpResponseMessage Canned(string content)
{
var completion = new
{
choices = new[] { new { message = new { role = "assistant", content } } },
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(completion),
Encoding.UTF8, "application/json"),
};
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}
}

View File

@@ -29,6 +29,8 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
/// <summary>Create a fully-populated gerbil and return its id.</summary> /// <summary>Create a fully-populated gerbil and return its id.</summary>
private async Task<Guid> CreateFullGerbil(string name = "TestTier") private async Task<Guid> CreateFullGerbil(string name = "TestTier")
{ {
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
// when multiple tests in the same fixture share the SQLite connection.
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
{ {
name, name,
@@ -39,7 +41,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
characterTraits = new[] { "neugierig", "zutraulich" }, characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie", characterNote = "Liebling der Familie",
notes = "Eine Notiz", notes = "Eine Notiz",
externalRef = "ext-001", externalRef = $"ext-{name.GetHashCode():X8}",
})); }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode); Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync()); return ExtractId(await resp.Content.ReadAsStringAsync());
@@ -67,7 +69,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes")); Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.Equal("ext-001", GetStr(json, "externalRef")); Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? ""); Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote")); Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
} }
@@ -95,7 +97,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype")); Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes")); Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.Equal("ext-001", GetStr(json, "externalRef")); Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? ""); Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Neue Notiz", GetStr(json, "characterNote")); Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
} }
@@ -122,7 +124,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name")); Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes")); Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder")); Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("ext-001", GetStr(json, "externalRef")); Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? ""); Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote")); Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
} }

View File

@@ -49,6 +49,13 @@ public class ApplicationContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
// DB-4: ICU German collation on user-visible / searched text columns.
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
// support custom collation names and would fail EnsureCreated on the test host.
const string DeIcu = "de-x-icu";
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
modelBuilder.Entity<Gerbil>(e => modelBuilder.Entity<Gerbil>(e =>
{ {
// Enums persisted as their string names (readable, Gridify-friendly). // Enums persisted as their string names (readable, Gridify-friendly).
@@ -58,6 +65,14 @@ public class ApplicationContext : DbContext
// Residency defaults to true (own stock unless explicitly marked external). // Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true); e.Property(g => g.IsResident).HasDefaultValue(true);
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
if (isNpgsql)
{
e.Property(g => g.Name).UseCollation(DeIcu);
e.Property(g => g.NameSearch).UseCollation(DeIcu);
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
}
// FEAT-14: character traits stored as a JSON text column (works on both // FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary). // Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>( var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
@@ -81,6 +96,12 @@ public class ApplicationContext : DbContext
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.ColorVariety).WithMany() e.HasOne(g => g.ColorVariety).WithMany()
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
e.HasIndex(g => g.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
}); });
modelBuilder.Entity<Litter>(e => modelBuilder.Entity<Litter>(e =>
@@ -89,6 +110,12 @@ public class ApplicationContext : DbContext
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(l => l.Mother).WithMany() e.HasOne(l => l.Mother).WithMany()
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
// DB-5: ExternalRef = source litter id from extract.py.
// Unique (filtered, nulls allowed for manually-entered litters).
e.HasIndex(l => l.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
}); });
modelBuilder.Entity<HealthRecord>(e => modelBuilder.Entity<HealthRecord>(e =>
@@ -170,6 +197,15 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>() modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId }); .HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{
modelBuilder.Entity<ColorVariety>()
.Property(v => v.Name).UseCollation(DeIcu);
modelBuilder.Entity<Contact>()
.Property(c => c.Name).UseCollation(DeIcu);
}
SeedColorVarieties(modelBuilder); SeedColorVarieties(modelBuilder);
} }

View File

@@ -0,0 +1,28 @@
using GerbilManagerWebAPI.Import;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ImportDocxEndpoints
{
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/import/docx").WithTags("Import");
// POST /import/docx/dry-run — analyse without writing
group.MapPost("/dry-run",
async Task<Ok<ImportDocxReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
group.MapPost("/execute",
async Task<Ok<ImportDocxReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
return app;
}
}
}

View File

@@ -0,0 +1,38 @@
using GerbilManagerWebAPI.Names;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
///
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
/// frontend maps exactly this code to its German disabled-state hint.
/// </summary>
public static class NamesEndpoints
{
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/names/suggest", async (
string? letter,
string? gender,
string? usages,
int count,
NameSuggestionService service,
CancellationToken ct) =>
{
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
return result.Status switch
{
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
NameSuggestionStatus.NotConfigured => Results.Json(
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
_ => Results.Json(
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
};
})
.WithTags("Names");
return app;
}
}
}

View File

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,259 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
/// <summary>
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
/// docx_animals.json (produced by extract_docx.py) and enriches the database:
///
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
/// - Litter link: match docx WS-code to Litters.PairingCode → set Gerbil.LitterId
/// for animals matched by normalize(name) + litter birth date.
/// - ReceiverContact: lookup-or-create Contact by owner name → set ReceiverContactId.
/// - GoHomeDate, DateOfDeath, CauseOfDeath: fill if currently null (fill-NULL-only).
/// - NEVER overwrites a manually-set non-null value.
///
/// Idempotent: running multiple times is safe. Each run resolves whatever is still null.
/// Execute is gated by the endpoint; this service only acts when asked.
/// </summary>
public sealed class ImportDocxService
{
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
private readonly ApplicationContext _db;
private readonly string _sourceDir;
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
: this(db,
config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
{ }
public ImportDocxService(ApplicationContext db, string sourceDir)
{
_db = db;
_sourceDir = sourceDir;
}
public async Task<ImportDocxReport> RunAsync(bool execute)
{
var notes = new List<string>();
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
"extract_docx.py zuerst ausführen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, notes);
}
// Build lookup: PairingCode → Litter.Id (WS-code normalised: spaces removed)
var littersInDb = await _db.Litters
.Where(l => l.PairingCode != null)
.Select(l => new { l.Id, l.Date, l.PairingCode })
.ToListAsync();
var litterByWs = littersInDb
.GroupBy(l => l.PairingCode!.Replace(" ", ""))
.ToDictionary(g => g.Key, g => g.ToList());
// Build animal lookup: normalize(name) + litter_dob → Gerbil (for litter-link)
var gerbilsInDb = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath })
.ToListAsync();
var gerbilByKey = gerbilsInDb
.Where(g => g.DateOfBirth is not null)
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
.ToDictionary(g => g.Key, g => g.ToList());
// Contact lookup: normalized name → existing Contact
var contactsInDb = await _db.Contacts
.Select(c => new { c.Id, c.Name })
.ToListAsync();
var contactByNorm = contactsInDb
.GroupBy(c => NormalizeName(c.Name))
.ToDictionary(g => g.Key, g => g.First().Id);
int litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
foreach (var da in docxAnimals)
{
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
// Resolve the litter by WS-code + approximate birth date
Guid? litterId = null;
if (!string.IsNullOrWhiteSpace(da.WsCode) && !string.IsNullOrWhiteSpace(da.LitterDob))
{
var litterDob = ParseDate(da.LitterDob);
if (litterDob is not null && litterByWs.TryGetValue(da.WsCode.Replace(" ", ""), out var cands))
{
// Pick the litter whose date matches (within ±5 days for rounding)
var match = cands.FirstOrDefault(l =>
Math.Abs((l.Date.DayNumber - litterDob.Value.DayNumber)) <= 5);
litterId = match?.Id;
}
}
// Resolve the gerbil by name + litter birth date
var animalDob = litterId is not null
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
: ParseDate(da.LitterDob);
if (animalDob is null) { skipped++; continue; }
var key = NameDobKey(da.Name, animalDob.Value);
if (!gerbilByKey.TryGetValue(key, out var gerbilCands)) { skipped++; continue; }
// If multiple gerbils match (same name+dob), take the one without a litter link first
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
?? gerbilCands.First();
// Resolve receiver contact (lookup-or-create)
Guid? receiverId = null;
if (!string.IsNullOrWhiteSpace(da.Owner))
{
var normOwner = NormalizeName(da.Owner);
if (contactByNorm.TryGetValue(normOwner, out var existingId))
{
receiverId = existingId;
ownerLinked++;
}
else
{
ownerCreated++;
if (execute)
{
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
_db.Contacts.Add(newContact);
await _db.SaveChangesAsync();
receiverId = newContact.Id;
contactByNorm[normOwner] = receiverId.Value;
}
}
}
var goHomeDate = ParseDate(da.AbgabeDate);
var deathDate = ParseDate(da.DeathDate);
// Count what will change
bool willLinkLitter = litterId is not null && gerbilSnap.LitterId is null;
bool willFillGoHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
bool willFillDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
if (willLinkLitter) litterLinked++;
if (willFillGoHome) goHomeFilled++;
if (willFillDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
if (row is null) continue;
if (willLinkLitter) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null)
row.ReceiverContactId = receiverId;
if (willFillGoHome) row.GoHomeDate = goHomeDate;
if (willFillDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
}
if (execute && (litterLinked + goHomeFilled + deathFilled + ownerLinked + ownerCreated) > 0)
await _db.SaveChangesAsync();
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (WS-Code → PairingCode).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein DB-Match).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
return new ImportDocxReport(execute, litterLinked, ownerLinked + ownerCreated,
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
}
private T? Load<T>(string file)
{
var path = Path.Combine(_sourceDir, file);
if (!File.Exists(path)) return default;
using var fs = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(fs, Json);
}
private static DateOnly? ParseDate(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var m = System.Text.RegularExpressions.Regex.Match(s,
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
if (!m.Success) return null;
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
int y = int.Parse(m.Groups[3].Value);
if (y < 100) y += 2000;
try { return new DateOnly(y, mo, d); } catch { return null; }
}
private static string NameDobKey(string name, DateOnly dob)
{
var n = System.Text.RegularExpressions.Regex.Replace(
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
return $"{n}|{dob:yyyy-MM-dd}";
}
private static string NormalizeName(string name)
{
var n = (name ?? "").ToLowerInvariant();
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
return n;
}
}
// ---- Source shapes (from extract_docx.py output) ----
public sealed class DocxLitter
{
public string LitterId { get; set; } = "";
public string Dob { get; set; } = "";
public string MotherName { get; set; } = "";
public string FatherName { get; set; } = "";
public string WsCode { get; set; } = "";
public string Note { get; set; } = "";
}
public sealed class DocxAnimal
{
public string WsCode { get; set; } = "";
public string LitterDob { get; set; } = "";
public string Name { get; set; } = "";
public string Farbschlag { get; set; } = "";
public string Gender { get; set; } = "";
public string Owner { get; set; } = "";
public string AbgabeDate { get; set; } = "";
public string AbgabeWeight { get; set; } = "";
public string DeathDate { get; set; } = "";
public string DeathCause { get; set; } = "";
public string PartnerName { get; set; } = "";
public string PartnerDob { get; set; } = "";
}
// ---- Report ----
public sealed record ImportDocxReport(
bool Executed,
int LitterLinked,
int OwnerLinked,
int GoHomeFilled,
int DeathFilled,
int ContactsCreated,
int Skipped,
IReadOnlyList<string> Notes);
}

View File

@@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
int AlreadyImported, int AlreadyImported,
QuarantineSummary Quarantined, QuarantineSummary Quarantined,
int ParentLinksFromChart = 0, int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0); int ConflictsResolvedByDecision = 0,
int FarbschlagDerivedFromGenotype = 0);
public sealed record QuarantineSummary( public sealed record QuarantineSummary(
int Conflicts, int Conflicts,

View File

@@ -71,17 +71,21 @@ namespace GerbilManagerWebAPI.Import
else if (conf == "niedrig") dateOnly++; else if (conf == "niedrig") dateOnly++;
} }
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such // existing rows (idempotency).
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
var existingGerbilExtRefs = await _db.Gerbils var existingGerbilExtRefs = await _db.Gerbils
.Where(g => g.ExternalRef != null) .Where(g => g.ExternalRef != null)
.Select(g => g.ExternalRef!).ToListAsync(); .Select(g => g.ExternalRef!).ToListAsync();
var existingGerbilSet = existingGerbilExtRefs.ToHashSet(); var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
var existingLitterKeys = await _db.Litters // DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
.Select(l => new { l.Name, l.Date }).ToListAsync(); // Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
var existingLitterKeySet = existingLitterKeys var existingLitterData = await _db.Litters
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
var existingLitterKeySet = existingLitterData
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet(); .Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
var existingLitterExtRefSet = existingLitterData
.Where(x => x.ExternalRef != null)
.Select(x => x.ExternalRef!).ToHashSet();
// colour-variety name -> id (case-insensitive) // colour-variety name -> id (case-insensitive)
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync(); var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
@@ -94,10 +98,10 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet(); var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ---- // ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date, // COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
// so their existingLitterKeySet key was always "" → they were always counted as // so skip them early — they can never be created or linked to animals.
// "created" even though the execute block skipped them (date is DateOnly d = false).
// Fix: skip undated litters early — they can never be created or linked to animals.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0; int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters) foreach (var sl in litters)
@@ -107,7 +111,10 @@ namespace GerbilManagerWebAPI.Import
var name = $"Wurf {sl.LitterId}".Trim(); var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}"; var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; } // DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
// for litters imported before ExternalRef existed (those have ExternalRef = null).
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
{ littersExisting++; continue; }
var id = Guid.NewGuid(); var id = Guid.NewGuid();
litterIdMap[sl.Id] = id; litterIdMap[sl.Id] = id;
@@ -122,6 +129,7 @@ namespace GerbilManagerWebAPI.Import
TotalBorn = sl.TotalBorn, TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note, Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer, PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
}); });
} }
if (samples.Count < 8) if (samples.Count < 8)
@@ -145,25 +153,63 @@ namespace GerbilManagerWebAPI.Import
.ToDictionary(g => g.ExternalRef!, g => g.LitterId); .ToDictionary(g => g.ExternalRef!, g => g.LitterId);
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null) var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId); .ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.Id);
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
var varietiesWithGeno = await _db.ColorVarieties
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet). // PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
var plan = new List<AnimalPlan>(); var plan = new List<AnimalPlan>();
int fbDerivedFromGenotype = 0;
foreach (var a in loadable) foreach (var a in loadable)
{ {
bool exists = existingGerbilSet.Contains(a.Id); bool exists = existingGerbilSet.Contains(a.Id);
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid(); // CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
Guid gid;
if (exists)
{
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
{
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
else
{
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
continue;
}
}
}
else gid = Guid.NewGuid();
Guid? wurfLitterId = null; Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 } if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid)) && litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
wurfLitterId = lid; wurfLitterId = lid;
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
Guid? colorVarietyId = null; Guid? colorVarietyId = null;
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants) var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
.Where(s => !string.IsNullOrWhiteSpace(s)); .Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var fb in fbCandidates) foreach (var fb in fbCandidates)
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid)) if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
{ colorVarietyId = vid; break; } { colorVarietyId = vid; break; }
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
{
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
// would match any catalog entry via wildcards and produce false positives.
var composed = ComposeGenotype(a.Genotype);
if (!composed.Contains("??"))
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
}
var gender = InferGender(a, sireNames, damNames); var gender = InferGender(a, sireNames, damNames);
var norm = Normalize(StripZucht(a.Name)); var norm = Normalize(StripZucht(a.Name));
@@ -469,6 +515,33 @@ namespace GerbilManagerWebAPI.Import
int farbschlagWouldRebackfill = plan.Count(p => int farbschlagWouldRebackfill = plan.Count(p =>
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId); p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
{
var noColor = await _db.Gerbils
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
.Select(g => new { g.Id, g.Genotype })
.ToListAsync();
foreach (var g in noColor)
{
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
Guid? derivedVid = null;
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
{ derivedVid = v.Id; break; }
if (derivedVid is null) continue;
fbDerivedFromGenotype++;
if (execute)
{
var row = await _db.Gerbils.FindAsync(g.Id);
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
}
}
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
}
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose // HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'. // OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen"). // NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
@@ -524,6 +597,8 @@ namespace GerbilManagerWebAPI.Import
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision); int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
if (conflictsResolvedByDecision > 0) if (conflictsResolvedByDecision > 0)
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich)."); notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
if (fbDerivedFromGenotype > 0)
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport( return new ImportReport(
@@ -532,7 +607,7 @@ namespace GerbilManagerWebAPI.Import
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded, conflictsResolvedByDecision), parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
Photos: new PhotoSummary(photosAttached, photosMissing), Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples, Samples: samples,
Notes: notes, Notes: notes,
@@ -570,6 +645,23 @@ namespace GerbilManagerWebAPI.Import
private static string StripCaret(string allele) => allele.Replace("^", ""); private static string StripCaret(string allele) => allele.Replace("^", "");
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
{
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (a.Length < 8 || c.Length < 8) return false;
for (int i = 0; i < 8; i++)
{
if (a[i] == "??" || c[i] == "??") continue;
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
}
return true;
}
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams) private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
{ {
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it // Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class UniqueExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGermanCollation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalRef",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.CreateIndex(
name: "IX_Litters_ExternalRef",
table: "Litters",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Litters_ExternalRef",
table: "Litters");
migrationBuilder.DropColumn(
name: "ExternalRef",
table: "Litters");
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterLetter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LitterLetter",
table: "Litters",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LitterLetter",
table: "Litters");
}
}
}

View File

@@ -201,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -689,7 +690,8 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("text"); .HasColumnType("text");
@@ -777,16 +779,19 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("NameSearch") b.Property<string>("NameSearch")
.HasColumnType("text"); .HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("OriginBreeder") b.Property<string>("OriginBreeder")
.HasColumnType("text"); .HasColumnType("text")
.UseCollation("de-x-icu");
b.Property<Guid?>("OriginContactId") b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -807,6 +812,10 @@ namespace GerbilManagerWebAPI.Migrations
b.HasIndex("EnclosureId"); b.HasIndex("EnclosureId");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("LitterId"); b.HasIndex("LitterId");
b.HasIndex("OriginContactId"); b.HasIndex("OriginContactId");
@@ -890,9 +899,15 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateOnly?>("ExpectedGoHomeDate") b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date"); .HasColumnType("date");
b.Property<string>("ExternalRef")
.HasColumnType("text");
b.Property<Guid?>("FatherId") b.Property<Guid?>("FatherId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("LitterLetter")
.HasColumnType("text");
b.Property<Guid?>("MotherId") b.Property<Guid?>("MotherId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -911,6 +926,10 @@ namespace GerbilManagerWebAPI.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("FatherId"); b.HasIndex("FatherId");
b.HasIndex("MotherId"); b.HasIndex("MotherId");

View File

@@ -25,5 +25,14 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code; /// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary> /// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
public string? PairingCode { get; set; } public string? PairingCode { get; set; }
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
/// Name+Date is the fallback for litters created before this column existed.</summary>
public string? ExternalRef { get; set; }
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
} }
} }

View File

@@ -0,0 +1,5 @@
namespace GerbilManagerWebAPI.Names
{
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
}

View File

@@ -0,0 +1,107 @@
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Ai;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Names
{
/// <summary>
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
/// Returns NotConfigured when the AI section is missing — callers map to 503.
/// </summary>
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
};
public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count,
CancellationToken ct = default)
{
var aiResult = await _client.CompleteAsync(
BuildSystemPrompt(),
BuildUserPrompt(letter, gender, usages, count),
ct);
if (aiResult.Status == AiCallStatus.NotConfigured)
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
var suggestions = ParseSuggestions(aiResult.Text);
return suggestions is null
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
}
internal static string BuildSystemPrompt() =>
"Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{
var sb = new StringBuilder();
sb.Append($"Schlage {count} Rennmaus-Namen vor");
if (!string.IsNullOrWhiteSpace(letter))
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
if (!string.IsNullOrWhiteSpace(usages))
sb.Append($", aus den Kulturkreisen: {usages}");
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString();
}
/// <summary>
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
/// its JSON output, then deserialises the array.
/// </summary>
internal static List<NameSuggestion>? ParseSuggestions(string raw)
{
var text = raw.Trim();
// Strip ```json ... ``` or ``` ... ``` fences.
if (text.StartsWith("```", StringComparison.Ordinal))
{
var firstNewline = text.IndexOf('\n');
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
if (text.EndsWith("```", StringComparison.Ordinal))
text = text[..^3].TrimEnd();
}
// Find the JSON array bounds defensively.
var start = text.IndexOf('[');
var end = text.LastIndexOf(']');
if (start < 0 || end <= start) return null;
text = text[start..(end + 1)];
try
{
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
}
catch (JsonException)
{
return null;
}
}
}
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
public sealed record NameSuggestionResult(
NameSuggestionStatus Status,
List<NameSuggestion>? Suggestions,
string? Error);
}

View File

@@ -1,5 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints; using GerbilManagerWebAPI.Endpoints;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore; using Scalar.AspNetCore;
@@ -44,9 +45,22 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client). // INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>( builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); http => http.Timeout = TimeSpan.FromSeconds(60));
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
builder.Services.AddDataProtection(); // AR-3: persist the key ring so encrypted passwords survive image redeployments.
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
{
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
Directory.CreateDirectory(keyRingPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
.SetApplicationName("GerbilManager");
}
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>(); builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>(); builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>(); builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
@@ -84,11 +98,13 @@ app.MapInbreedingEndpoints();
app.MapPhotoEndpoints(); app.MapPhotoEndpoints();
app.MapSaleAdEndpoints(); app.MapSaleAdEndpoints();
app.MapImportEndpoints(); app.MapImportEndpoints();
app.MapImportDocxEndpoints();
app.MapContractEndpoints(); app.MapContractEndpoints();
app.MapSettingsEndpoints(); app.MapSettingsEndpoints();
app.MapExportEndpoints(); app.MapExportEndpoints();
app.MapCmsEndpoints(); app.MapCmsEndpoints();
app.MapRequestEndpoints(); app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.Run(); app.Run();

View File

@@ -2,150 +2,80 @@
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN > ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen > **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt"). > („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet > und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren > das, was **noch offen** ist. Nichts geht verloren.
> alles wartet geduldig, bis ihr antwortet.
_Stand: 2026-06-06._ _Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
Der Manager ist fertig und läuft. Die alten Daten sind importiert große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel Feinschliff** — siehe unten._
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
--- ---
## A. Für Julian — Konten, Schlüssel, Technik ## A. Zugänge & Technik (für Julian)
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade | | # | Was gebraucht wird | Schaltet frei |
|---|---|---|---| |---|---|---|
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang | | **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 | | **A4** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) | | **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) | | **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo → Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) |
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~**AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
## B. Für Julian — kleine Aktionen (jederzeit) ## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum | | # | Aktion | Warum |
|---|---|---| |---|---|---|
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. | | **B2** | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt (http://192.168.2.124:5173). |
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
--- ## C. Genetik-Feinschliff (optional, blockiert nichts)
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung | # | Frage | Betrifft |
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
| # | Frage | Blockiert |
|---|---|---| |---|---|---|
| ~~C1~~ | **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt | | **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. |
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt | | **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). |
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt | | **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert) ## E. Charakterbogen-Eigenschaften (optional)
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
--- Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?**
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|---|---|---|
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-****DD** | ✅ **D-** (DD war Tippfehler) — Julian |
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-****PP** | ✅ **PP** — Julian |
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-****DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-****Cc[h]** · E-Locus: **E-****EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee****ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`****ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
>
> Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | ✅ **DD** — Julian |
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]****Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
### D5 · **Sterbedatum** widersprüchlich
| Tier | Problem |
|---|---|
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
| Tier | Konkreter Konflikt — was stimmt? |
|---|---|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP****Pp** |
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp****spsp** (Schecke ja/nein) |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016****2018** |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
---
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
sagen, was ergänzt oder gestrichen werden soll:**
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen · > zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig · > lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft > buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?) - **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?)
- **E2** — Auch **ehrliche negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives? - **E2** — Auch ehrliche **negative** Eigenschaften aufnehmen (z. B. *beißt manchmal*) für ehrliche Inserate?
---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? |
|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? |
|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** |
| Percy of little runners (*16.12.2017) | P-Locus: **PP****Pp** |
| Iwana of little runners (*02.10.2018) | P-Locus: **PP****Pp** |
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-****Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** |
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
--- ---
@@ -153,16 +83,25 @@ sagen, was ergänzt oder gestrichen werden soll:**
| Funktion | Status | Wartet auf | | Funktion | Status | Wartet auf |
|---|---|---| |---|---|---|
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) | | KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
| Charakterbogen → KI-Text | gebaut, schläft | **A1** | | E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen | | Öffentliche Webseite + KI-Chat-Editor | gebaut | **A4** (Domain + Cloudflare) |
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) | | NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) | | Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
--- ---
*Einfach abschnittsweise antwortenMichael arbeitet die Antworten ein, streicht ## ✅ Erledigt (Archivnichts geht verloren)
sie hier und meldet, was dadurch live geht.*
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
- **C1C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
- **D1D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*

View File

@@ -1,4 +1,4 @@
# GerbilManager Produktionskonfiguration # GerbilManager Produktionskonfiguration
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start. # Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen) # Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
@@ -15,9 +15,15 @@ TAG=latest
PGDATA_PATH=/mnt/SSD/gerbil/pgdata PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups BACKUPS_PATH=/mnt/SSD/gerbil/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden) # KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
ANTHROPIC_API_KEY= # Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
AI__BaseUrl=
AI__ApiKey=
AI__Model=gemini-flash-latest

View File

@@ -1,4 +1,4 @@
# GerbilManager — TrueNAS Custom Application # GerbilManager — TrueNAS Custom Application
# ============================================== # ==============================================
# Vor dem ersten Start: # Vor dem ersten Start:
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte. # 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
@@ -40,10 +40,17 @@ services:
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}" ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos) # Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
Photos__RootPath: /data/photos Photos__RootPath: /data/photos
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden) # AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" DataProtection__KeyRingPath: /data/keys
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}"
volumes: volumes:
- photos:/data/photos - photos:/data/photos
- keys:/data/keys
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -101,6 +108,14 @@ volumes:
type: none type: none
o: bind o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}" device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys:
driver: local
driver_opts:
type: none
o: bind
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
backups: backups:
driver: local driver: local
driver_opts: driver_opts:

View File

@@ -1,4 +1,4 @@
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a) # KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen** `POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`), Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten | | Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|---|---|---|---| |---|---|---|---|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier | | **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier | | **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier | | **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal | | **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
```powershell ```powershell
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai' $env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
$env:AI__ApiKey = '<schlüssel>' $env:AI__ApiKey = '<schlüssel>'
$env:AI__Model = 'gemini-2.0-flash' $env:AI__Model = 'gemini-flash-latest'
``` ```
oder per user-secrets im API-Projekt: oder per user-secrets im API-Projekt:
@@ -46,5 +46,5 @@ services:
environment: environment:
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
AI__Model: gemini-2.0-flash AI__Model: gemini-flash-latest
``` ```

View File

@@ -40,6 +40,25 @@ npm run build
npm run preview npm run preview
``` ```
## Farbschlag-Katalog (AR-5)
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
Nach jeder Änderung dort den Generator laufen lassen:
```bash
npm run gen:catalog
```
Erzeugt zwei Artefakte und committet beide:
| Datei | Notation | Verwendung |
|---|---|---|
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
## E2E-Tests (QA-1, Playwright) ## E2E-Tests (QA-1, Playwright)
```bash ```bash

View File

@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 201, contract) return json(route, 201, contract)
} }
// FEAT-NAMEGEN: /names/suggest
if (path === '/names/suggest' && method === 'GET') {
if (!db.namesConfigured) {
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
}
const letter = url.searchParams.get('letter')?.toUpperCase()
const allSuggestions = [
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
]
const result = letter
? allSuggestions.filter((s) => s.name.startsWith(letter))
: allSuggestions
return json(route, 200, result)
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -74,6 +74,8 @@ export interface MockDb {
// ABGABE: Verträge + KI-Inserat-Flag // ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[] contracts: MockContract[]
saleAdConfigured: boolean saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
} }
function gerbil( function gerbil(
@@ -292,5 +294,6 @@ export function seedDb(): MockDb {
mailConfigured: true, mailConfigured: true,
contracts: [], contracts: [],
saleAdConfigured: true, saleAdConfigured: true,
namesConfigured: true,
} }
} }

View File

@@ -0,0 +1,59 @@
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.namegen
const tf = de.pages.gerbils.form
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
// Panel öffnen
await page.getByRole('button', { name: t.button }).click()
await expect(page.getByText(t.panelTitle)).toBeVisible()
// Vorschläge laden
await page.getByRole('button', { name: t.loadButton }).click()
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
})
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
// Klick auf Vorschlag 'Fenrir'
await page.getByRole('button', { name: 'Fenrir' }).click()
// Panel geschlossen, Name-Feld befüllt
await expect(page.getByText(t.panelTitle)).toBeHidden()
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
})
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByLabel(t.letterLabel).fill('F')
await page.getByRole('button', { name: t.loadButton }).click()
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
// Artemis (A) nicht sichtbar
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
})
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
skipUnlessMock()
if (mockDb) mockDb.namesConfigured = false
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByText(t.keyMissing)).toBeVisible()
})

View File

@@ -0,0 +1,169 @@
/**
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
*
* Abgedeckte Änderungen:
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
*
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
*/
import { de, expect, skipUnlessLive, test } from './fixtures'
const API = 'http://localhost:5179'
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
skipUnlessLive()
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
// eef = e/ef het → E>e>ef Rank → ee[f]
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
await expect(genoCode).toBeVisible({ timeout: 8_000 })
// Rohes 'eef' darf nicht mehr im Code-Block stehen
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
})
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
skipUnlessLive()
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
await expect(genoCode).toBeVisible({ timeout: 8_000 })
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
})
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
skipUnlessLive()
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
// Ccchm → Cc[chm]; eef → ee[f]
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
const genoCode = page.locator('code')
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
// Rohes Storage-Format darf nicht sichtbar sein
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
})
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
page,
}) => {
skipUnlessLive()
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
await expect(originInput).toBeVisible()
await expect(originInput).toHaveValue('Kleinen Chaoten')
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
await expect(deafLabel).toBeVisible()
// Dropdown-Select im Label-Kontext
const deafSelect = deafLabel.locator('select')
await expect(deafSelect).toBeVisible()
})
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
skipUnlessLive()
// Snapshot VORHER
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
const snap = await before.json()
expect(snap.originBreeder).toBe('Kleinen Chaoten')
expect(snap.genotype).toBeTruthy()
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
data: {
name: snap.name,
gender: snap.gender,
status: snap.status,
isResident: snap.isResident,
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
},
})
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
expect(res.status()).toBe(204)
// Re-Fetch und vergleichen
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
const updated = await afterRes.json()
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
})
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
skipUnlessLive()
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
await page.goto('/rennmaeuse')
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
const placeholder = page.getByText('(ohne Namen)')
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
if (await toggle.isVisible()) await toggle.click()
}
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
})
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
skipUnlessLive()
// Stammbaum eines Tiers mit namenlosem Elternteil
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
const placeholder = page.getByText('(ohne Namen)')
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
if (visible) {
await expect(placeholder.first()).toBeVisible()
}
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
})
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
skipUnlessLive()
await page.goto('/becken')
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
})
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
skipUnlessLive()
await page.goto('/')
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
await expect(
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
).toBeVisible()
})

View File

@@ -54,6 +54,23 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible() await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
}) })
test('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
const nameLink = page.getByRole('link', { name: 'Fridolin' })
await expect(nameLink).toBeVisible()
// href korrekt gesetzt
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
await nameLink.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
})
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => { test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub/stammbaum') await page.goto('/rennmaeuse/nameless-stub/stammbaum')

View File

@@ -172,3 +172,27 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
await page.goto('/rennmaeuse/kruemel/bearbeiten') await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true') await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
}) })
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
// Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible()
// Vater-Badge sichtbar
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
// Navigation zum Wurf funktioniert
await litterLink.first().click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
})

View File

@@ -0,0 +1,49 @@
/**
* Katalog-Generator — AR-5
*
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
*
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
* → Quelle für UI-Dropdowns, Frontend-Suche.
*
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
* → Quelle für künftige EF-Seed-Migrationen (Pam).
* NICHT in Bracket-Notation ändern — Backend-Parser
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
*
* Ausführen nach jeder Änderung an catalog.ts:
* npm run gen:catalog
*
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
*/
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
import { LOCUS_ORDER } from './src/genetics/loci.ts'
import type { Genotype } from './src/genetics/genotype.ts'
import { writeFileSync } from 'fs'
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
function toInternalString(g: Genotype): string {
return LOCUS_ORDER.filter(
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
.join(' ')
}
// ── Display artefact (bracket notation) ─────────────────────────────────────
const displayPath = './src/genetics/colorVarietySeed.generated.json'
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
const backendSeed = BASE_COLORS.map((entry, i) => ({
name: entry.name,
...(entry.english !== undefined ? { english: entry.english } : {}),
canonicalGenotype: toInternalString(representativeGenotype(entry)),
sortOrder: i,
...(entry.image !== undefined ? { image: entry.image } : {}),
}))
const backendPath = './src/genetics/colorVarietySeed.backend.json'
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)

View File

@@ -10,7 +10,8 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"e2e": "playwright test" "e2e": "playwright test",
"gen:catalog": "npx tsx gen-seed.mts"
}, },
"dependencies": { "dependencies": {
"jszip": "^3.10.1", "jszip": "^3.10.1",

View File

@@ -0,0 +1,73 @@
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
import { describe, expect, it } from 'vitest'
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
describe('buildSuggestPath', () => {
it('includes uppercased letter', () => {
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
expect(path).toContain('letter=F')
})
it('omits letter when blank', () => {
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('omits letter when only whitespace', () => {
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('includes gender', () => {
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
expect(path).toContain('gender=male')
})
it('omits gender for empty string', () => {
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
expect(path).not.toContain('gender=')
})
it('joins multiple usages without encoding commas', () => {
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
expect(path).toContain('usages=norn,mythg')
})
it('omits usages when array is empty', () => {
const path = buildSuggestPath({ usages: [] })
expect(path).not.toContain('usages=')
})
it('defaults count to 6', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toContain('count=6')
})
it('uses provided count', () => {
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
expect(path).toContain('count=8')
})
it('starts with /names/suggest', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toMatch(/^\/names\/suggest\?/)
})
})
describe('NAMEGEN_USAGES', () => {
it('contains all 5 expected culture codes', () => {
const codes = NAMEGEN_USAGES.map((u) => u.code)
expect(codes).toContain('norn')
expect(codes).toContain('japa')
expect(codes).toContain('mythg')
expect(codes).toContain('ger')
expect(codes).toContain('arb')
expect(codes).toHaveLength(5)
})
it('every usage has a non-empty label', () => {
for (const u of NAMEGEN_USAGES) {
expect(u.label.length).toBeGreaterThan(0)
}
})
})

View File

@@ -0,0 +1,40 @@
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
import { api } from './client'
export interface NameSuggestion {
name: string
meaning: string
origin: string
}
export const NAMEGEN_USAGES = [
{ code: 'norn', label: 'Nordisch' },
{ code: 'japa', label: 'Japanisch' },
{ code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' },
] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
export interface SuggestNamesParams {
letter?: string
gender?: string
usages: NamegenUsageCode[]
count?: number
}
/** Exported for unit tests — builds the query path without a network call. */
export function buildSuggestPath(params: SuggestNamesParams): string {
const parts: string[] = []
const letter = params.letter?.trim().toUpperCase()
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
parts.push(`count=${params.count ?? 6}`)
return `/names/suggest?${parts.join('&')}`
}
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
return api.get<NameSuggestion[]>(buildSuggestPath(params))
}

View File

@@ -43,16 +43,29 @@ export default function BreedingResultView({ result, title }: BreedingResultView
) : ( ) : (
<> <>
<ul className="farbschlag-cards"> <ul className="farbschlag-cards">
{result.byFarbschlag.map((f) => ( {result.byFarbschlag.map((f) => {
<li key={f.farbschlag} className="farbschlag-card"> // Representative genotype: highest-prob offspring for this farbschlag.
<FarbschlagImage name={f.farbschlag} /> const repGenotype = result.offspring.find(
<span className="farbschlag-card__name">{f.farbschlag}</span> (o) => o.farbschlag === f.farbschlag,
<span className="farbschlag-card__prob"> )?.genotype
{f.probability.percent} return (
<small> ({f.probability.text})</small> <li key={f.farbschlag} className="farbschlag-card">
</span> <div className="farbschlag-card__img">
</li> <FarbschlagImage name={f.farbschlag} />
))} </div>
<div className="farbschlag-card__body">
<span className="farbschlag-card__name">{f.farbschlag}</span>
{repGenotype && (
<code className="farbschlag-card__geno">{repGenotype}</code>
)}
</div>
<span className="farbschlag-card__prob">
{f.probability.percent}
<small> ({f.probability.text})</small>
</span>
</li>
)
})}
</ul> </ul>
<button <button

View File

@@ -1,5 +1,5 @@
import { de } from '../strings/de' import { de } from '../strings/de'
import { ALL_TRAITS } from '../format/traits' import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import './charakterbogen.css' import './charakterbogen.css'
export interface CharakterbogenProps { export interface CharakterbogenProps {
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
onNoteChange: (note: string) => void onNoteChange: (note: string) => void
} }
/**
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
* Controlled & reusable: rendered on the animal detail page (persisted) and in
* the Abgabe listing composer (feeds the AI sale-text). German labels from
* de.character.traits; the stored value is the trait KEY.
*/
export default function Charakterbogen({ export default function Charakterbogen({
traits, traits,
note, note,
@@ -29,26 +23,32 @@ export default function Charakterbogen({
const next = new Set(selected) const next = new Set(selected)
if (next.has(key)) next.delete(key) if (next.has(key)) next.delete(key)
else next.add(key) else next.add(key)
// Preserve the vocabulary order for stable output. // Preserve vocabulary (category) order for stable output.
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key)) onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
} }
return ( return (
<div className="charakterbogen"> <div className="charakterbogen">
<ul className="trait-grid"> {TRAIT_CATEGORIES.map((cat) => (
{ALL_TRAITS.map((tr) => ( <section key={cat.category} className="trait-category">
<li key={tr.key}> <h4 className="trait-category__heading">{cat.category}</h4>
<label className="trait-chip"> <ul className="trait-grid">
<input {cat.traits.map((tr) => (
type="checkbox" <li key={tr.key}>
checked={selected.has(tr.key)} <label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
onChange={() => toggle(tr.key)} <input
/> type="checkbox"
<span>{tr.label}</span> checked={selected.has(tr.key)}
</label> onChange={() => toggle(tr.key)}
</li> />
))} <span>{tr.label}</span>
</ul> {tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
</label>
</li>
))}
</ul>
</section>
))}
<label className="field"> <label className="field">
<span>{t.noteLabel}</span> <span>{t.noteLabel}</span>
<textarea <textarea

View File

@@ -0,0 +1,114 @@
.namegen-name-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.namegen-name-row .input {
flex: 1;
min-width: 0;
}
.namegen-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-bg, #fff);
}
.namegen-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-weight: 600;
}
.namegen-panel__filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.namegen-panel__letter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-panel__letter-input {
width: 5rem;
}
.namegen-panel__usages {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-usages-grid {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
}
.namegen-usages-grid label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.9rem;
cursor: pointer;
}
.namegen-suggestions {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
border-top: 1px solid var(--color-border);
}
.namegen-suggestion {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.namegen-suggestion:last-child {
border-bottom: none;
}
.namegen-suggestion__pick {
font-weight: 600;
color: var(--color-accent, #2563eb);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
font-size: inherit;
font-family: inherit;
min-width: 6rem;
}
.namegen-suggestion__pick:hover {
text-decoration: underline;
}
.namegen-suggestion__meaning {
flex: 1;
font-size: 0.88rem;
color: var(--color-text-muted);
min-width: 8rem;
}
.namegen-suggestion__origin {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { ApiError, errorCode } from '../api/client'
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
import './NameSuggestPanel.css'
interface NameSuggestPanelProps {
gender: string
onPick: (name: string) => void
onClose: () => void
}
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
const t = de.namegen
const [letter, setLetter] = useState('')
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
const [loading, setLoading] = useState(false)
const [keyMissing, setKeyMissing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
const [fetched, setFetched] = useState(false)
function toggleUsage(code: NamegenUsageCode) {
setUsages((prev) => {
const next = new Set(prev)
if (next.has(code)) next.delete(code)
else next.add(code)
return next
})
}
async function load() {
setLoading(true)
setError(null)
setKeyMissing(false)
setSuggestions([])
try {
const result = await suggestNames({
letter: letter.trim() || undefined,
gender: gender || undefined,
usages: [...usages] as NamegenUsageCode[],
count: 6,
})
setSuggestions(result)
setFetched(true)
} catch (err) {
if (errorCode(err) === 'NamesKeyMissing') {
setKeyMissing(true)
} else {
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
}
} finally {
setLoading(false)
}
}
return (
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
<div className="namegen-panel__header">
<span>{t.panelTitle}</span>
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
</button>
</div>
<div className="namegen-panel__filters">
<div className="namegen-panel__letter">
<label htmlFor="namegen-letter">{t.letterLabel}</label>
<input
id="namegen-letter"
className="input namegen-panel__letter-input"
value={letter}
onChange={(e) => setLetter(e.target.value)}
placeholder={t.letterPlaceholder}
maxLength={1}
/>
</div>
<div className="namegen-panel__usages">
<span>{t.usagesLabel}</span>
<div className="namegen-usages-grid">
{NAMEGEN_USAGES.map(({ code, label }) => (
<label key={code}>
<input
type="checkbox"
checked={usages.has(code)}
onChange={() => toggleUsage(code)}
/>
{label}
</label>
))}
</div>
</div>
</div>
<button
type="button"
className="btn btn--primary"
onClick={load}
disabled={loading || usages.size === 0}
>
{loading ? t.loading : t.loadButton}
</button>
{keyMissing && <p className="muted">{t.keyMissing}</p>}
{error && <p className="error-text">{error}</p>}
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{suggestions.length > 0 && (
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
{suggestions.map((s, i) => (
<li key={`${s.name}-${i}`} className="namegen-suggestion">
<button
type="button"
className="namegen-suggestion__pick"
onClick={() => onPick(s.name)}
>
{s.name}
</button>
<span className="namegen-suggestion__meaning">{s.meaning}</span>
<span className="namegen-suggestion__origin">{s.origin}</span>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -1,8 +1,21 @@
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */ /* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
.trait-category {
margin-bottom: 0.25rem;
}
.trait-category__heading {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted, #666);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0.75rem 0 0.35rem;
}
.charakterbogen .trait-grid { .charakterbogen .trait-grid {
list-style: none; list-style: none;
margin: 0 0 0.75rem; margin: 0 0 0.5rem;
padding: 0; padding: 0;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -39,3 +52,16 @@
min-height: 0; min-height: 0;
flex: 0 0 auto; flex: 0 0 auto;
} }
.trait-chip--warn {
border-color: #d97706;
background: #fff7ed;
}
.trait-warn-badge {
margin-left: auto;
font-size: 0.68rem;
font-weight: 600;
color: #d97706;
white-space: nowrap;
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest'
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
describe('trait catalog', () => {
it('has 4 categories', () => {
expect(TRAIT_CATEGORIES).toHaveLength(4)
})
it('category names match spec', () => {
const names = TRAIT_CATEGORIES.map((c) => c.category)
expect(names).toEqual([
'Sozialverhalten',
'Eignung & Umgang',
'Hobbys & Eigenarten',
'Wesen & Temperament',
])
})
it('ALL_TRAITS flattens all categories', () => {
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
expect(ALL_TRAITS).toHaveLength(total)
})
it('all keys are unique', () => {
const keys = ALL_TRAITS.map((t) => t.key)
expect(new Set(keys).size).toBe(keys.length)
})
it('existing 15 keys are still present (stored on live animals)', () => {
const legacy = [
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
]
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
for (const key of legacy) {
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
}
})
it('new keys are present', () => {
const newKeys = [
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
'schredder', 'nestbauer', 'territorial',
]
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
for (const key of newKeys) {
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
}
})
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
expect(isWarnTrait('territorial')).toBe(true)
})
it('non-warn traits return false from isWarnTrait', () => {
expect(isWarnTrait('zutraulich')).toBe(false)
expect(isWarnTrait('dominant')).toBe(false)
expect(isWarnTrait('beobachtungstier')).toBe(false)
expect(isWarnTrait('schreckhaft')).toBe(false)
})
it('unknown key returns false from isWarnTrait', () => {
expect(isWarnTrait('not-a-real-key')).toBe(false)
})
it('traitLabel returns German label for known key', () => {
expect(traitLabel('zutraulich')).toBe('zutraulich')
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
expect(traitLabel('territorial')).toBe('territorial')
})
it('traitLabel returns the key itself for unknown key', () => {
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
})
it('traitLabels maps a list of keys to labels', () => {
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
'zutraulich',
'dominant (Leittier)',
])
})
it('traitLabels handles null and undefined gracefully', () => {
expect(traitLabels(null)).toEqual([])
expect(traitLabels(undefined)).toEqual([])
})
})

View File

@@ -1,9 +1,23 @@
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */ /** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
import { de } from '../strings/de' import { de } from '../strings/de'
export const ALL_TRAITS = de.character.traits export interface TraitEntry {
key: string
label: string
warn?: true
}
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label])) export interface TraitCategory {
category: string
traits: readonly TraitEntry[]
}
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
export function traitLabel(key: string): string { export function traitLabel(key: string): string {
return LABEL_BY_KEY.get(key) ?? key return LABEL_BY_KEY.get(key) ?? key
@@ -13,3 +27,7 @@ export function traitLabel(key: string): string {
export function traitLabels(keys: readonly string[] | null | undefined): string[] { export function traitLabels(keys: readonly string[] | null | undefined): string[] {
return (keys ?? []).map(traitLabel) return (keys ?? []).map(traitLabel)
} }
export function isWarnTrait(key: string): boolean {
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
}

View File

@@ -0,0 +1,24 @@
/**
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
* aus catalog.ts übereinstimmen.
*
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
* und die geänderten JSON-Dateien committen.
*/
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { describe, it, expect } from 'vitest'
import { CATALOG } from '../catalog'
const __dir = dirname(fileURLToPath(import.meta.url))
describe('AR-5 Catalog drift-guard', () => {
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
expect(committed).toEqual(Array.from(CATALOG))
})
})

View File

@@ -179,17 +179,21 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag') expect(match.name).toBe('Unbekannter Farbschlag')
}) })
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => { it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
// GEN-3f: 73 -> 61 (cchm CP reconciliation). // GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell), expect(CATALOG_SIZE).toBe(70)
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
expect(CATALOG_SIZE).toBe(66)
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
// The first 18 are the frozen ColorVariety keys — their representative // The first 18 are the frozen ColorVariety keys — their representative
// genotype MUST resolve back to their own name, never a later variety. // genotype MUST resolve back to their own name, never a later variety.
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
// because the REW engine check (both C-alleles reduced + pp) fires first.
// PEW stays in the catalog as a user-pickable import name; its computed
// farbschlag is intentionally 'REW' per Julian's extended rule.
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) { for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
if (REW_SHADOWED.has(entry.name)) continue
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name) expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
} }
}) })
@@ -222,8 +226,9 @@ describe('Farbschlag catalog', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE) expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 }) expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name. // Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length) expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true) expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
}) })
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -372,9 +377,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
}) })
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => { describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => { it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe( expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Fuchsschimmel', 'Kohlfuchsschimmel',
) )
}) })
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => { it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
@@ -400,9 +407,11 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti') expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
}) })
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => { it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe( expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Fuchsschimmel', 'Kohlfuchsschimmel',
) )
}) })
}) })
@@ -420,7 +429,8 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell') expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell') expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell') expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell') // GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
}) })
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => { it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -470,8 +480,8 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti') expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs') expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs') expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'. // GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs') expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C). // A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel') expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
}) })
@@ -480,11 +490,12 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
const siam = BASE_COLORS.find((e) => e.name === 'Siam')! const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')! const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')! const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')! const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam') expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell') expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell') expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell') expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
}) })
}) })
@@ -506,7 +517,8 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell') expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell') expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell') expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell') // GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell') expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
}) })
@@ -523,3 +535,151 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell') expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
}) })
}) })
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('dd entries use Dilute prefix — no more X-dd names', () => {
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
})
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW — subsumes PEW)
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
// Counterproof: full C present → NOT REW (residual pigment)
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
})
it('Farbarten (categories) never appear as computed results', () => {
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
for (const cat of CATS) {
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
}
})
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
})
})
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
// ── Display symbols ────────────────────────────────────────────────────
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp rere',
)
// C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp rere',
)
// C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp rere',
)
})
// ── E-locus display order: E > e > e[f] ─────────────────────────────
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
// Stored canonical: [ef, e] (ef dominant over e in storage).
// Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
})
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp rere',
)
})
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp rere',
)
})
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe(display)
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
})
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display)
})
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd'])
expect(toDisplayString(g)).toBe(display)
})
// ── Parser accepts both forms ─────────────────────────────────────────
it('bracket input round-trips identically to internal-symbol input', () => {
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
)
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
)
})
it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
})
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
const g = fromDisplayString(input)
expect(g.E).toEqual(['e', '?'])
expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
})
})

View File

@@ -66,8 +66,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ 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', 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: '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: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.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' },
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' }, { name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ── // ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' }, { name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
@@ -78,16 +78,24 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.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: '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', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5). // GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' }, // 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: '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', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' }, { name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' }, { name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' }, { name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' }, { name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
// agouti+dilute+fox combinations not otherwise covered in the catalog.
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the // GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here. // A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' }, { name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' }, { name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' }, { name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
@@ -104,8 +112,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' }, { name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' }, { name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' }, { name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' }, { name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' }, { name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f/3g: c^chm colourpoint varieties ── // ── GEN-3f/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell). // GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -147,10 +155,12 @@ export interface FarbschlagMatch {
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the * Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a * MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not * match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/ * a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'. * allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the * for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
* raw pair, which runs before this.) * entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
* variety specifically.
*/ */
function locusToken(g: Genotype, locus: LocusKey): string { function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the // Default an unknown allele to the WILD-TYPE reading: most-dominant for the
@@ -162,7 +172,9 @@ function locusToken(g: Genotype, locus: LocusKey): string {
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a)) const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
if (locus === 'E') { if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E' if (x === y) return x // ee->'e', efef->'ef', EE->'E'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef' // GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
// enables catalog entries like Kohlfuchsschimmel to match het animals).
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
return dominantAllele('E', x, y) // E/ef, E/e -> 'E' return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
} }
return dominantAllele(locus, x, y) return dominantAllele(locus, x, y)
@@ -175,10 +187,12 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
} }
/** /**
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even * E-locus family: used to scope the catalog search to E-aware entries.
* when other loci are unknown (so genotypes never fall through to "Unbekannt"). * Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now) * implies a non-dominant extension pair, or null for full-extension/unknown.
* Returns null when E is dominant (full colour) or fully unknown. * GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
* They are ONLY used here as catalog-search filters; they must NEVER appear as
* computed farbschlag output (the farbschlagFor category guard blocks them).
*/ */
function eFamily(g: Genotype): string | null { function eFamily(g: Genotype): string | null {
const [x, y] = g.E const [x, y] = g.E
@@ -205,7 +219,10 @@ function baseColourFor(g: Genotype): string | null {
const base = family const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null) ? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null) : (BASE_COLORS.find((e) => matches(g, e)) ?? null)
return base?.name ?? family // GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
return base?.name ?? null
} }
/** /**
@@ -233,7 +250,13 @@ function colourpointName(g: Genotype): string | null {
} }
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix. // A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] })) const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
const DILUTE = 'Dilute '
if (base.startsWith(DILUTE)) {
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
}
return `CP-${base}${bothCchm ? '' : '-Hell'}`
} }
export function farbschlagFor(g: Genotype): FarbschlagMatch { export function farbschlagFor(g: Genotype): FarbschlagMatch {
@@ -241,8 +264,30 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke') if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex') if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
// cchm/cchm + pp → REW (CP varieties with pink-eye)
// cchm/ch + pp → REW (het colourpoint + pink-eye)
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
const [c0, c1] = resolvedPair(g, 'C')
const [p0, p1] = resolvedPair(g, 'P')
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
const name = ['REW', ...modifiers].join(' ')
return { name, base: null, unknown: false }
}
const baseName = colourpointName(g) ?? baseColourFor(g) const baseName = colourpointName(g) ?? baseColourFor(g)
if (!baseName) { // GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
// a computed Farbschlag output. If baseName is a category label, treat as
// Unbekannt instead of leaking an invalid name into the UI.
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
'Standard', 'Colourpoint', 'Dilute',
'Fuchs', 'Fuchsschimmel', 'Schimmel',
'Colourpoint Dilute',
])
if (!baseName || CATEGORY_NAMES.has(baseName)) {
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true } return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
} }
const name = [baseName, ...modifiers].join(' ') const name = [baseName, ...modifiers].join(' ')

View File

@@ -0,0 +1,426 @@
[
{
"name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"sortOrder": 0,
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
},
{
"name": "Hermelin",
"english": "Dark Tailed White",
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
"sortOrder": 1,
"image": "hermelin.jpeg"
},
{
"name": "Himalaya",
"english": "Himalayan",
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
"sortOrder": 2,
"image": "himalaya.jpg"
},
{
"name": "Zobel",
"english": "Sable",
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
"sortOrder": 3,
"image": "zobel.jpeg"
},
{
"name": "Rotaugenschimmel",
"english": "Red-Eyed Roan",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"sortOrder": 4,
"image": "rotaugen-schimmel.jpg"
},
{
"name": "Agouti",
"english": "Golden Agouti",
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
"sortOrder": 5,
"image": "agouti-mit-erklaerung-der-genloci.JPG"
},
{
"name": "Schwarz",
"english": "Black",
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
"sortOrder": 6,
"image": "schwarz.jpg"
},
{
"name": "Silberagouti",
"english": "Grey Agouti",
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
"sortOrder": 7,
"image": "silberagouti.jpg"
},
{
"name": "Anthrazit",
"english": "Slate",
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
"sortOrder": 8,
"image": "anthrazit.jpg"
},
{
"name": "Algierfuchs",
"english": "Dark-Eyed Honey",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 9,
"image": "algierfuchs.jpg"
},
{
"name": "Blau",
"english": "Blue",
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
"sortOrder": 10,
"image": "blau-schwarz-dd.JPG"
},
{
"name": "Gold",
"english": "Argente Golden",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 11,
"image": "gold.jpg"
},
{
"name": "Platin",
"english": "Lilac",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 12,
"image": "platin.JPG"
},
{
"name": "Goldfuchs",
"english": "Yellow Fox",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 13,
"image": "goldfuchs.jpg"
},
{
"name": "Rotfuchs",
"english": "Argente Nutmeg",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 14,
"image": "rotfuchs.JPG"
},
{
"name": "Dilute Gold",
"english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 15,
"image": "gold-dd.jpg"
},
{
"name": "Dilute Platin",
"english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"sortOrder": 16,
"image": "platin-dd.jpg"
},
{
"name": "Altweiss (REW)",
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
"sortOrder": 17,
"image": "altweiss-rew.jpeg"
},
{
"name": "Apricot (Blassfuchs)",
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
"sortOrder": 18,
"image": "apricot-blassfuchs.jpg"
},
{
"name": "Blaufuchs",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 19,
"image": "blaufuchs.jpg"
},
{
"name": "C-Separator",
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
"sortOrder": 20,
"image": "c-separator.jpg"
},
{
"name": "Elfenbein",
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
"sortOrder": 21,
"image": "elfenbein.jpg"
},
{
"name": "Kohlfuchs",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 22,
"image": "kohlfuchs.jpg"
},
{
"name": "Polarfuchs",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 23,
"image": "polarfuchs.jpg"
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 24,
"image": "saphir.jpg"
},
{
"name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg"
},
{
"name": "Topas",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 26,
"image": "topas.jpg"
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 27,
"image": "platin-hell.jpg"
},
{
"name": "Dilute Agouti",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"sortOrder": 28,
"image": "agouti-dd.jpg"
},
{
"name": "Dilute Silberagouti",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"sortOrder": 29,
"image": "silberagouti-dd.jpg"
},
{
"name": "Dilute Kohlfuchs",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"sortOrder": 30,
"image": "kohlfuchs-dd.jpg"
},
{
"name": "Dilute Anthrazit",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"sortOrder": 31,
"image": "anthrazit-dd.jpg"
},
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
"sortOrder": 35
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"sortOrder": 36,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"sortOrder": 37,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"sortOrder": 38,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"sortOrder": 39,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"sortOrder": 40,
"image": "blaufuchsschimmel.jpg"
},
{
"name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 41,
"image": "kohlfuchs-hell.jpg"
},
{
"name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 42,
"image": "goldfuchs-hell.jpg"
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"sortOrder": 43,
"image": "goldfuchsschimmel.jpg"
},
{
"name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 44,
"image": "gold-hell.jpg"
},
{
"name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 45,
"image": "blaufuchs-hell.jpeg"
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"sortOrder": 46,
"image": "rotfuchsschimmel.jpg"
},
{
"name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 47,
"image": "polarfuchs-hell.jpeg"
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg"
},
{
"name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 49,
"image": "rotfuchs-hell.jpg"
},
{
"name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 50,
"image": "kohlfuchs-hell-2.jpg"
},
{
"name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 51,
"image": "algierfuchs-hell.JPG"
},
{
"name": "Dilute Topas",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 52,
"image": "topas-dd.jpg"
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"sortOrder": 53,
"image": "blaufuchs-dd.jpg"
},
{
"name": "Marder",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
"sortOrder": 54,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
"sortOrder": 55,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
"sortOrder": 56,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"sortOrder": 57,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"sortOrder": 58
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 59,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 61,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"sortOrder": 62
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 63,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"sortOrder": 64
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 65
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 66
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 67
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 68
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"sortOrder": 69
}
]

View File

@@ -2,35 +2,35 @@
{ {
"name": "Pink Eyed White (PEW)", "name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White", "english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere", "canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
"sortOrder": 0, "sortOrder": 0,
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg" "image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
}, },
{ {
"name": "Hermelin", "name": "Hermelin",
"english": "Dark Tailed White", "english": "Dark Tailed White",
"canonicalGenotype": "aa chch DD EE GG PP spsp rere", "canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 1, "sortOrder": 1,
"image": "hermelin.jpeg" "image": "hermelin.jpeg"
}, },
{ {
"name": "Himalaya", "name": "Himalaya",
"english": "Himalayan", "english": "Himalayan",
"canonicalGenotype": "AA chch DD EE GG PP spsp rere", "canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 2, "sortOrder": 2,
"image": "himalaya.jpg" "image": "himalaya.jpg"
}, },
{ {
"name": "Zobel", "name": "Zobel",
"english": "Sable", "english": "Sable",
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere", "canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 3, "sortOrder": 3,
"image": "zobel.jpeg" "image": "zobel.jpeg"
}, },
{ {
"name": "Rotaugenschimmel", "name": "Rotaugenschimmel",
"english": "Red-Eyed Roan", "english": "Red-Eyed Roan",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 4, "sortOrder": 4,
"image": "rotaugen-schimmel.jpg" "image": "rotaugen-schimmel.jpg"
}, },
@@ -105,14 +105,14 @@
"image": "rotfuchs.JPG" "image": "rotfuchs.JPG"
}, },
{ {
"name": "dd Gold", "name": "Dilute Gold",
"english": "dd Argente Golden", "english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere", "canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 15, "sortOrder": 15,
"image": "gold-dd.jpg" "image": "gold-dd.jpg"
}, },
{ {
"name": "dd Platin", "name": "Dilute Platin",
"english": "dd Lilac", "english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere", "canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"sortOrder": 16, "sortOrder": 16,
@@ -168,7 +168,7 @@
}, },
{ {
"name": "Orangeschimmel", "name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 25, "sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg" "image": "schimmel-orangeschimmel.jpg"
}, },
@@ -185,222 +185,242 @@
"image": "platin-hell.jpg" "image": "platin-hell.jpg"
}, },
{ {
"name": "Agouti dd", "name": "Dilute Agouti",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere", "canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"sortOrder": 28, "sortOrder": 28,
"image": "agouti-dd.jpg" "image": "agouti-dd.jpg"
}, },
{ {
"name": "Silberagouti dd", "name": "Dilute Silberagouti",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere", "canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"sortOrder": 29, "sortOrder": 29,
"image": "silberagouti-dd.jpg" "image": "silberagouti-dd.jpg"
}, },
{ {
"name": "Kohlfuchs dd", "name": "Dilute Kohlfuchs",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere", "canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"sortOrder": 30, "sortOrder": 30,
"image": "kohlfuchs-dd.jpg" "image": "kohlfuchs-dd.jpg"
}, },
{ {
"name": "Anthrazit dd", "name": "Dilute Anthrazit",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere", "canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"sortOrder": 31, "sortOrder": 31,
"image": "anthrazit-dd.jpg" "image": "anthrazit-dd.jpg"
}, },
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
"sortOrder": 35
},
{ {
"name": "Silberschimmel", "name": "Silberschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 32, "sortOrder": 36,
"image": "silberschimmel.jpg" "image": "silberschimmel.jpg"
}, },
{ {
"name": "Polarfuchsschimmel", "name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 33, "sortOrder": 37,
"image": "polarfuchsschimmel.jpg" "image": "polarfuchsschimmel.jpg"
}, },
{ {
"name": "Algierfuchsschimmel", "name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 34, "sortOrder": 38,
"image": "algierfuchsschimmel.jpg" "image": "algierfuchsschimmel.jpg"
}, },
{ {
"name": "Kohlfuchsschimmel", "name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 35, "sortOrder": 39,
"image": "kohlfuchsschimmel.jpg" "image": "kohlfuchsschimmel.jpg"
}, },
{ {
"name": "Blaufuchsschimmel", "name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 36, "sortOrder": 40,
"image": "blaufuchsschimmel.jpg" "image": "blaufuchsschimmel.jpg"
}, },
{ {
"name": "Kohlfuchs, hell", "name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 37, "sortOrder": 41,
"image": "kohlfuchs-hell.jpg" "image": "kohlfuchs-hell.jpg"
}, },
{ {
"name": "Goldfuchs, hell", "name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere", "canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 38, "sortOrder": 42,
"image": "goldfuchs-hell.jpg" "image": "goldfuchs-hell.jpg"
}, },
{ {
"name": "Goldfuchsschimmel", "name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere", "canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 39, "sortOrder": 43,
"image": "goldfuchsschimmel.jpg" "image": "goldfuchsschimmel.jpg"
}, },
{ {
"name": "Gold-Hell", "name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere", "canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 40, "sortOrder": 44,
"image": "gold-hell.jpg" "image": "gold-hell.jpg"
}, },
{ {
"name": "Blaufuchs, hell", "name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere", "canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 41, "sortOrder": 45,
"image": "blaufuchs-hell.jpeg" "image": "blaufuchs-hell.jpeg"
}, },
{ {
"name": "Rotfuchsschimmel", "name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 42, "sortOrder": 46,
"image": "rotfuchsschimmel.jpg" "image": "rotfuchsschimmel.jpg"
}, },
{ {
"name": "Polarfuchs, hell", "name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere", "canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 43, "sortOrder": 47,
"image": "polarfuchs-hell.jpeg" "image": "polarfuchs-hell.jpeg"
}, },
{ {
"name": "Kohlfuchsschimmel, hell", "name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere", "canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 44, "sortOrder": 48,
"image": "kohlfuchsschimmel-hell.jpg" "image": "kohlfuchsschimmel-hell.jpg"
}, },
{ {
"name": "Rotfuchs, hell", "name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere", "canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 45, "sortOrder": 49,
"image": "rotfuchs-hell.jpg" "image": "rotfuchs-hell.jpg"
}, },
{ {
"name": "Kohlfuchs-Hell", "name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere", "canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 46, "sortOrder": 50,
"image": "kohlfuchs-hell-2.jpg" "image": "kohlfuchs-hell-2.jpg"
}, },
{ {
"name": "Algierfuchs, hell", "name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere", "canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 47, "sortOrder": 51,
"image": "algierfuchs-hell.JPG" "image": "algierfuchs-hell.JPG"
}, },
{ {
"name": "Topas dd", "name": "Dilute Topas",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere", "canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 48, "sortOrder": 52,
"image": "topas-dd.jpg" "image": "topas-dd.jpg"
}, },
{ {
"name": "Blaufuchs dd", "name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere", "canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"sortOrder": 49, "sortOrder": 53,
"image": "blaufuchs-dd.jpg" "image": "blaufuchs-dd.jpg"
}, },
{ {
"name": "Marder", "name": "Marder",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere", "canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 50, "sortOrder": 54,
"image": "marder.JPG" "image": "marder.JPG"
}, },
{ {
"name": "Siam", "name": "Siam",
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere", "canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 51, "sortOrder": 55,
"image": "siam-marder-hell.JPG" "image": "siam-marder-hell.JPG"
}, },
{ {
"name": "Zobel-Hell", "name": "Zobel-Hell",
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere", "canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 52, "sortOrder": 56,
"image": "zobel-hell.jpg" "image": "zobel-hell.jpg"
}, },
{ {
"name": "CP-Agouti", "name": "CP-Agouti",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 53, "sortOrder": 57,
"image": "agouti-cp.jpg" "image": "agouti-cp.jpg"
}, },
{ {
"name": "CP-Agouti-Hell", "name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 54 "sortOrder": 58
}, },
{ {
"name": "CP-Silberagouti", "name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 55, "sortOrder": 59,
"image": "silberagouti-cp.JPG" "image": "silberagouti-cp.JPG"
}, },
{ {
"name": "CP-Silberagouti-Hell", "name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 56 "sortOrder": 60
}, },
{ {
"name": "CP-Algierfuchs", "name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
"sortOrder": 57, "sortOrder": 61,
"image": "algierfuchs-cp.jpg" "image": "algierfuchs-cp.jpg"
}, },
{ {
"name": "CP-Algierfuchs-Hell", "name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
"sortOrder": 58 "sortOrder": 62
}, },
{ {
"name": "CP-Polarfuchs", "name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
"sortOrder": 59, "sortOrder": 63,
"image": "polarfuchs-cp.jpg" "image": "polarfuchs-cp.jpg"
}, },
{ {
"name": "CP-Polarfuchs-Hell", "name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere", "canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 61
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 62
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 63
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 64 "sortOrder": 64
}, },
{ {
"name": "CP-Orangeschimmel-Hell", "name": "CP-Fuchs",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere", "canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
"sortOrder": 65 "sortOrder": 65
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
"sortOrder": 66
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
"sortOrder": 67
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 68
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 69
} }
] ]

View File

@@ -74,6 +74,32 @@ export function wildType(): Genotype {
return out return out
} }
/**
* GEN-3h: breeder bracket-notation display symbols.
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
*/
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
ef: 'e[f]',
cchm: 'c[chm]',
ch: 'c[h]',
}
function displaySymbol(allele: string): string {
return DISPLAY_SYMBOL[allele] ?? allele
}
/**
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
* Storage/dominance order is E > ef > e; display swaps ef and e so that
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
*/
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
if (locus !== 'E') return pair
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
}
/** /**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". * Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* 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
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
* (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 * GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
* convention) — e.g. ['C','?'] renders "C-". * convention) — e.g. ['C','?'] renders "C-".
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
*/ */
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]).replace(/\?/g, '-')) .map((locus) => {
const [a, b] = displayPair(locus, g[locus])
return displaySymbol(a) + displaySymbol(b)
})
.map((s) => s.replace(/\?/g, '-'))
.join(' ') .join(' ')
} }
@@ -146,8 +178,19 @@ 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 '?' // GEN-3h: accept bracket display notation → canonical internal symbols.
// (the frozen storage contract keeps '?'; only DISPLAY renders '-'). t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
// 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(/\[-\]/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, '?') t = t.replace(/-/g, '?')
return t return t
} }

View File

@@ -539,28 +539,57 @@ textarea {
margin: 0.5rem 0 1rem; margin: 0.5rem 0 1rem;
padding: 0; padding: 0;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); /* GEN-4: wider min-width prevents long names (CP-Silberagouti-Hell) from overflowing */
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.5rem; gap: 0.5rem;
} }
/* GEN-4 layout fix: image left, body centre (name + genotype stacked), prob right.
align-items: stretch so all three columns fill the card height consistently. */
.farbschlag-card { .farbschlag-card {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
padding: 0.6rem 0.9rem; padding: 0.6rem 0.9rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.6rem; border-radius: 0.6rem;
background: var(--color-surface); background: var(--color-surface);
overflow: hidden; /* contain long genotype strings */
}
.farbschlag-card__img {
flex-shrink: 0;
}
/* Body takes the middle space and allows wrapping for long names */
.farbschlag-card__body {
flex: 1;
min-width: 0; /* allow flex child to shrink below content size */
display: flex;
flex-direction: column;
gap: 0.15rem;
} }
.farbschlag-card__name { .farbschlag-card__name {
font-weight: 600; font-weight: 600;
overflow-wrap: break-word;
word-break: break-word;
}
/* Genotype code shown below the name — muted, small, wrappable */
.farbschlag-card__geno {
font-size: 0.7rem;
color: var(--color-text-muted, #666);
overflow-wrap: break-word;
word-break: break-all;
opacity: 0.8;
} }
.farbschlag-card__prob { .farbschlag-card__prob {
flex-shrink: 0;
color: var(--color-accent); color: var(--color-accent);
white-space: nowrap; white-space: nowrap;
text-align: right;
} }
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile /* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile

View File

@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { de } from '../strings/de' import { de } from '../strings/de'
import { getGerbil, updateGerbil } from '../api/gerbils' import { getGerbil, updateGerbil } from '../api/gerbils'
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'
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
const enclosures = useApi(() => listEnclosures(), []) const enclosures = useApi(() => listEnclosures(), [])
const contacts = useApi(() => listContacts(), []) const contacts = useApi(() => listContacts(), [])
const litters = useApi(() => listLitters(), []) const litters = useApi(() => listLitters(), [])
const parentLitters = useApi(
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const colorName = useMemo( const colorName = useMemo(
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])), () => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>} {saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
</div> </div>
<h3>{t.detail.parentLittersTitle}</h3>
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
<p className="muted">{t.detail.parentLittersEmpty}</p>
)}
{(parentLitters.data?.items.length ?? 0) > 0 && (
<ul className="card-list">
{(parentLitters.data?.items ?? []).map((litter) => (
<li key={litter.id}>
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
<span className="gerbil-card__name">{litter.name}</span>
<span className="badge badge--active">
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
</span>
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
{litter.totalBorn !== null && (
<span className="gerbil-card__meta">
{litter.totalBorn} {de.pages.litters.countLabel}
</span>
)}
</Link>
</li>
))}
</ul>
)}
<h3 className="visually-hidden">{t.detail.moreData}</h3> <h3 className="visually-hidden">{t.detail.moreData}</h3>
<div className="tabs" role="tablist"> <div className="tabs" role="tablist">
{(['photos', 'health', 'weight'] as const).map((key) => ( {(['photos', 'health', 'weight'] as const).map((key) => (

View File

@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels' import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString } from '../genetics' import { fromDisplayString } from '../genetics'
import FarbschlagImage from '../components/FarbschlagImage' import FarbschlagImage from '../components/FarbschlagImage'
import NameSuggestPanel from '../components/NameSuggestPanel'
import '../components/NameSuggestPanel.css'
interface FormState { interface FormState {
name: string name: string
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
const [form, setForm] = useState<FormState>(EMPTY) const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({}) const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [initializedFor, setInitializedFor] = useState<string | null>(null) const [initializedFor, setInitializedFor] = useState<string | null>(null)
const [showNameSuggest, setShowNameSuggest] = useState(false)
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id]) const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
const colorVarieties = useApi(() => listColorVarieties(), []) const colorVarieties = useApi(() => listColorVarieties(), [])
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2> <h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
<form className="form" onSubmit={onSubmit} noValidate> <form className="form" onSubmit={onSubmit} noValidate>
<label className="field"> <div className="field">
<span>{t.fields.name} *</span> <label htmlFor="gerbil-name">{t.fields.name} *</label>
<input <div className="namegen-name-row">
className="input" <input
value={form.name} id="gerbil-name"
onChange={(e) => set('name', e.target.value)} className="input"
aria-invalid={Boolean(errors.name)} value={form.name}
/> onChange={(e) => set('name', e.target.value)}
aria-invalid={Boolean(errors.name)}
/>
<button
type="button"
className="btn"
onClick={() => setShowNameSuggest((v) => !v)}
>
{de.namegen.button}
</button>
</div>
{errors.name && <small className="error-text">{errors.name}</small>} {errors.name && <small className="error-text">{errors.name}</small>}
</label> </div>
{showNameSuggest && (
<NameSuggestPanel
gender={form.gender}
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
onClose={() => setShowNameSuggest(false)}
/>
)}
<label className="field"> <label className="field">
<span>{t.fields.gender} *</span> <span>{t.fields.gender} *</span>

View File

@@ -377,7 +377,13 @@ function PedigreeCard({
<div className="pedigree-card__body"> <div className="pedigree-card__body">
<div className="pedigree-card__name"> <div className="pedigree-card__name">
<SexIcon gender={g.gender} /> <SexIcon gender={g.gender} />
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span> <Link
to={`/rennmaeuse/${g.id}`}
className="pedigree-card__nametext"
onClick={(e) => e.stopPropagation()}
>
{g.name || de.pages.gerbils.nameless}
</Link>
</div> </div>
{farbschlag && ( {farbschlag && (
<span <span

View File

@@ -134,6 +134,12 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: inherit;
text-decoration: none;
}
.pedigree-card__nametext:hover {
text-decoration: underline;
} }
.pedigree-card__sex--male { .pedigree-card__sex--male {

View File

@@ -105,6 +105,11 @@ export const de = {
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.', tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.', notFound: 'Diese Rennmaus wurde nicht gefunden.',
moreData: 'Weitere Daten', moreData: 'Weitere Daten',
// ANIMAL-LITTERS: Würfe als Elternteil
parentLittersTitle: 'Würfe als Elternteil',
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter',
}, },
// Formular (anlegen/bearbeiten) // Formular (anlegen/bearbeiten)
form: { form: {
@@ -779,9 +784,9 @@ export const de = {
}, },
unknownFarbschlag: 'Unbekannter Farbschlag', unknownFarbschlag: 'Unbekannter Farbschlag',
}, },
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ── // ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt). // Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl. // Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
character: { character: {
sectionTitle: 'Charakter & Eigenschaften', sectionTitle: 'Charakter & Eigenschaften',
noteLabel: 'Notizen zum Charakter', noteLabel: 'Notizen zum Charakter',
@@ -789,22 +794,53 @@ export const de = {
save: 'Charakter speichern', save: 'Charakter speichern',
saved: 'Charakter gespeichert.', saved: 'Charakter gespeichert.',
none: 'Noch keine Eigenschaften ausgewählt.', none: 'Noch keine Eigenschaften ausgewählt.',
traits: [ warnLabel: '⚠ Warnsignal',
{ key: 'zutraulich', label: 'zutraulich' }, traitCategories: [
{ key: 'handzahm', label: 'handzahm' }, {
{ key: 'neugierig', label: 'neugierig' }, category: 'Sozialverhalten',
{ key: 'aufgeschlossen', label: 'aufgeschlossen' }, traits: [
{ key: 'ruhig', label: 'ruhig / ausgeglichen' }, { key: 'dominant', label: 'dominant (Leittier)' },
{ key: 'lebhaft', label: 'lebhaft / aktiv' }, { key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
{ key: 'verschmust', label: 'verschmust' }, { key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
{ key: 'eigenstaendig', label: 'eigenständig' }, { key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' }, ],
{ key: 'futterfreudig', label: 'futterfreudig' }, },
{ key: 'buddelt', label: 'buddelt gern' }, {
{ key: 'klettert', label: 'klettert gern' }, category: 'Eignung & Umgang',
{ key: 'laufrad', label: 'läuft gern im Laufrad' }, traits: [
{ key: 'vertraeglich', label: 'gut verträglich' }, { key: 'anfaengergeeignet', label: 'anfängergeeignet' },
{ key: 'schreckhaft', label: 'schreckhaft' }, { key: 'erfahrene-halter', label: 'für erfahrene Halter' },
{ key: 'beobachtungstier', label: 'reines Beobachtungstier' },
{ key: 'familiengeeignet', label: 'familiengeeignet / stressresistent' },
],
},
{
category: 'Hobbys & Eigenarten',
traits: [
{ key: 'futterfreudig', label: 'futterfreudig' },
{ key: 'buddelt', label: 'buddelt gern' },
{ key: 'klettert', label: 'klettert gern' },
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
{ key: 'schredder', label: 'Schredder-Meister' },
{ key: 'nestbauer', label: 'Nestbauer / Architekt' },
{ key: 'territorial', label: 'territorial', warn: true },
],
},
{
category: 'Wesen & Temperament',
traits: [
{ key: 'zutraulich', label: 'zutraulich' },
{ key: 'handzahm', label: 'handzahm' },
{ key: 'neugierig', label: 'neugierig' },
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
{ key: 'verschmust', label: 'verschmust' },
{ key: 'eigenstaendig', label: 'eigenständig' },
{ key: 'vertraeglich', label: 'gut verträglich' },
{ key: 'schreckhaft', label: 'schreckhaft' },
],
},
], ],
}, },
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ── // ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
@@ -813,6 +849,19 @@ export const de = {
resetButton: 'Filter zurücksetzen', resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen', closeButton: 'Schließen',
}, },
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
namegen: {
button: 'Name vorschlagen',
panelTitle: 'Namensvorschläge',
letterLabel: 'Anfangsbuchstabe',
letterPlaceholder: 'z. B. A',
usagesLabel: 'Herkunftskultur',
loadButton: 'Vorschläge laden',
loading: 'Lade Vorschläge …',
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
close: 'Schließen',
},
} as const } as const
export type Strings = typeof de export type Strings = typeof de

View File

@@ -84,6 +84,41 @@
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).", "decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]", "genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4" "source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Little Runner's Big Ben",
"dob": "03.02.2020",
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Vance Jr. von den Kleinen Chaoten",
"dob": "10.04.2022",
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Skarlett von den Kleinen Chaoten",
"dob": "14.07.2013",
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
"dateOfDeath": "17.04.2016",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Kazu von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Hanami von den Kleinen Chaoten",
"dob": "10.09.2015",
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
"dateOfDeath": "12.12.2019",
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
} }
] ]
} }

View File

@@ -973,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path):
continue continue
a["resolvedByDecision"] = True a["resolvedByDecision"] = True
if d.get("genotype"): if d.get("genotype"):
a["genotype"] = gt.parse(d["genotype"]) # CR-10: validate the parsed genotype — a typo'd decision string yields empty
# mapped8locus and would silently blank the animal's genotype while marking it
# 'resolved'. Only apply if the parse produces non-empty loci.
parsed = gt.parse(d["genotype"])
if parsed.get("mapped8locus"):
a["genotype"] = parsed
else:
# Keep the existing genotype; flag as a warning in the report.
a.setdefault("decisionWarnings", []).append(
f"Ungültiger Override-Genotyp '{d['genotype']}'"
"konnte nicht geparst werden (mapped8locus leer). "
"Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
)
if d.get("farbschlag"): if d.get("farbschlag"):
a["farbschlag"] = d["farbschlag"] a["farbschlag"] = d["farbschlag"]
a["farbschlagVariants"] = [d["farbschlag"]] a["farbschlagVariants"] = [d["farbschlag"]]

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""FEAT-8d Stage 1 — Wurfchronik-Detail-Dokument (.docx) extrahieren.
Liest 'Wurfchronik der Kleinen Chaoten im Detail.docx' (Word/XML, stdlib-Python,
kein pip) und erzeugt:
output/docx_litters.json — Wurf-Kopfdaten (WS-Code, DOB, Eltern, Notiz)
output/docx_animals.json — Tier-Zeilen (Name, Farbe, Abnehmer, ABD, Tod)
Format der Ausgabe ist so gestaltet, dass ImportDocxService.cs in C# direkt
darüber laden kann. Idempotent: mehrfaches Ausführen überschreibt denselben Output.
Bekannte Sonderwerte im Dokument:
ZT = Zucht-Tier (bleibt in Zucht, kein externer Abnehmer)
BLEIBT = vorläufig beim Züchter
FREI = noch verfügbar
VG: = Verpaarungs-Geschichte (bisherige Partner; nicht als Abnehmer werten)
RG: = Rückgabe
BEW = Bewerbung (Adoptionsinteressent in Prüfung)
-- ??? = Platzhalter, kein echter Name
Feld 'gender': '' = weiblich (kein Marker), '*' auf Farbschlag oder 'G'-Spalte = männlich.
Ausführung: python extract_docx.py [--docx PFAD]
"""
import os
import re
import sys
import json
import zipfile
import argparse
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_DOCX = os.path.join(
r"C:\Users\gulum\dev",
"Wurfchronik der Kleinen Chaoten im Detail.docx",
)
OUT = os.path.join(HERE, "output")
# --- Regex patterns -------------------------------------------------------
# Litter header paragraph (after whitespace-collapsing).
# Edge cases handled:
# - Dual birth date: "*16./17.03.2021"
# - WS without numerator: "WS: /5"
# - WS with trailing text: "WS: 4/4, davon 1 später..."
# - No space before WS: "...ChaotenWS: 2/4"
# Date part allows simple DD.MM.YYYY, dual-day (16./17.03.2021), or dual-month (31.05/*01.06.2023).
# We capture the LAST complete DD.MM.YYYY in the date token as the birth date.
_DATE_TOKEN = r"[\d./\*]+"
# Full litter header regex
LITTER_RE = re.compile(
r"([A-Za-z\d\-]*Wurf)\s*\*\s*(" + _DATE_TOKEN + r")"
r"\s*Von:\s*(.+?)\s*&\s*(.+?)\s*WS:\s*(\d*\s*/\s*\d+)"
r"(?:[,\s].*?)?(?:Notiz:\s*(.*?))?$",
re.IGNORECASE,
)
# Used to extract the canonical date from a date token like "31.05/*01.06.2023"
_LAST_DATE_RE = re.compile(r"(\d{1,2}\.\d{2}\.\d{4})(?![\d.])")
# Death/adoption date at start of combined T.D column: "16.09.23Tumor am After"
DATE_START_RE = re.compile(r"^(\d{1,2}\.\d{1,2}\.\d{2,4})\s*(.*)")
# Partner birth date: "Crow (*25.12.20)" or "Tom (*05.01.21)"
PARTNER_DOB_RE = re.compile(r"\(\s*\*\s*(\d{2}\.\d{2}\.\d{2,4})\s*\)")
# Special-value sentinel names to skip
PLACEHOLDER_NAMES = {"--", "???", ""}
INTERNAL_TOKENS = {"ZT", "BLEIBT", "FREI", "VG:", "VG*:", "RG:", "BEW"}
def _norm_dob(d: str) -> str:
"""Normalise German date to DD.MM.YYYY."""
if not d:
return ""
p = d.strip().split(".")
if len(p) == 3:
y = p[2].strip()
if len(y) == 2:
y = "20" + y
return f"{p[0].zfill(2)}.{p[1].zfill(2)}.{y}"
return d.strip()
def _cell_text(cell_xml: str) -> str:
"""Strip XML from a <w:tc> cell and return clean text."""
t = re.sub(r"<[^>]+>", "", cell_xml)
t = t.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
t = t.replace("&apos;", "'").replace("&quot;", '"')
return re.sub(r"\s+", " ", t).strip()
def _is_internal(value: str) -> bool:
"""True if the owner/name field holds an internal sentinel, not a real person."""
v = value.strip()
return v in INTERNAL_TOKENS or any(v.startswith(tok) for tok in INTERNAL_TOKENS)
def extract(docx_path: str):
"""Parse the docx and return (litters, animals) lists."""
with zipfile.ZipFile(docx_path) as z:
xml = z.read("word/document.xml").decode("utf-8", errors="replace")
# ---- Paragraphs → litter header blocks ----
paras = re.findall(r"<w:p[ >].*?</w:p>", xml, re.DOTALL)
para_texts = []
for p in paras:
t = re.sub(r"<[^>]+>", "", p)
t = t.replace("&amp;", "&").strip()
t = re.sub(r"\s+", " ", t).strip()
if t:
para_texts.append(t)
litters: list[dict] = []
current_ws: str = ""
current_litter_dob: str = ""
# Build a WS-code → litter index for assigning animals
ws_to_idx: dict[str, int] = {}
for para in para_texts:
m = LITTER_RE.search(para)
if not m:
continue
litter_id = m.group(1).strip()
dob_raw = m.group(2).strip()
mother_raw = m.group(3).strip()
father_raw = m.group(4).strip()
ws_raw = m.group(5).replace(" ", "")
note = (m.group(6) or "").strip()
# For dual-date tokens like "31.05/*01.06.2023", take the last full date.
last_dates = _LAST_DATE_RE.findall(dob_raw)
dob_clean = _norm_dob(last_dates[-1] if last_dates else dob_raw)
litter = {
"litterId": litter_id,
"dob": dob_clean,
"motherName": mother_raw,
"fatherName": father_raw,
"wsCode": ws_raw,
"note": note,
}
ws_to_idx[ws_raw] = len(litters)
litters.append(litter)
# ---- Tables → animal rows ----
# Each table sits after a litter-header paragraph; we sequence tables and
# litter headers together by their byte offset in the XML.
animals: list[dict] = []
# Build ordered sequence of (offset, type, data) events
events: list[tuple[int, str, any]] = []
for m in re.finditer(r"<w:p[ >].*?</w:p>", xml, re.DOTALL):
t = re.sub(r"<[^>]+>", "", m.group()).replace("&amp;", "&").strip()
t = re.sub(r"\s+", " ", t).strip()
lm = LITTER_RE.search(t)
if lm:
ws = lm.group(5).replace(" ", "")
dob_tok = lm.group(2)
last = _LAST_DATE_RE.findall(dob_tok)
dob = _norm_dob(last[-1] if last else dob_tok)
events.append((m.start(), "litter", (ws, dob)))
for m in re.finditer(r"<w:tbl[ >].*?</w:tbl>", xml, re.DOTALL):
events.append((m.start(), "table", m.group()))
events.sort(key=lambda e: e[0])
active_ws = ""
active_dob = ""
for _, etype, edata in events:
if etype == "litter":
active_ws, active_dob = edata
elif etype == "table" and active_ws:
# Parse all rows in this table
rows = re.findall(r"<w:tr[ >].*?</w:tr>", edata, re.DOTALL)
for row in rows:
cells_xml = re.findall(r"<w:tc[ >].*?</w:tc>", row, re.DOTALL)
ct = [_cell_text(c) for c in cells_xml]
if not ct:
continue
# Skip header rows
if ct[0] == "G" and len(ct) > 1 and "Farbe" in ct[1]:
continue
# Column positions: G | Farbe | Name | Partner | Abnehmer | ABD | T.D
# Some newer tables add ABGew between ABD and T.D (7 or 8 cols)
g_col = ct[0] if len(ct) > 0 else ""
farbe_raw = ct[1] if len(ct) > 1 else ""
name = ct[2] if len(ct) > 2 else ""
partner = ct[3] if len(ct) > 3 else ""
owner = ct[4] if len(ct) > 4 else ""
abd_raw = ct[5] if len(ct) > 5 else ""
# If 8 cols, col 6 = ABGew, col 7 = T.D; if 7 cols, col 6 = T.D
if len(ct) >= 8:
abgew = ct[6]
tod_raw = ct[7]
elif len(ct) >= 7:
abgew = ""
tod_raw = ct[6]
else:
abgew = ""
tod_raw = ""
# Skip placeholders
name = name.strip()
if name in PLACEHOLDER_NAMES:
continue
if not farbe_raw.strip() and not name:
continue
# Gender: explicit marker in G column, or * suffix on Farbschlag
is_male = bool(g_col.strip() == "*" or farbe_raw.endswith("*"))
farbschlag = farbe_raw.rstrip("*").strip()
# Owner: strip internal sentinels
owner_clean = owner.strip()
if _is_internal(owner_clean):
owner_clean = ""
# For multi-owner ("1.) Julia2.) RG:"), take first
m1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", owner_clean)
if m1:
owner_clean = m1.group(1).strip()
# ABD (Abgabe-Datum)
abgabe_date = _norm_dob(abd_raw.strip())
# T.D column: may start with a date followed by cause
death_date = ""
death_cause = ""
if tod_raw:
dm = DATE_START_RE.match(tod_raw.strip())
if dm:
death_date = _norm_dob(dm.group(1))
death_cause = dm.group(2).strip()
else:
death_cause = tod_raw.strip()
# Partner name and DOB
partner_clean = partner.strip()
partner_dob = ""
pdob_m = PARTNER_DOB_RE.search(partner_clean)
if pdob_m:
partner_dob = _norm_dob(pdob_m.group(1))
partner_clean = PARTNER_DOB_RE.sub("", partner_clean).strip()
# Strip VG:/ZT/etc. prefixes
partner_clean = re.sub(r"^(?:VG\*?:|ZT\s*)", "", partner_clean).strip()
# Take first partner in numbered list
pm1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", partner_clean)
if pm1:
partner_clean = pm1.group(1).strip()
animals.append({
"wsCode": active_ws,
"litterDob": active_dob,
"name": name,
"farbschlag": farbschlag,
"gender": "male" if is_male else "female",
"owner": owner_clean,
"abgabeDate": abgabe_date,
"abgabeWeight": abgew.strip(),
"deathDate": death_date,
"deathCause": death_cause,
"partnerName": partner_clean,
"partnerDob": partner_dob,
})
return litters, animals
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="FEAT-8d docx extractor")
ap.add_argument("--docx", default=DEFAULT_DOCX,
help="Pfad zur 'im Detail.docx'")
args = ap.parse_args()
if not os.path.isfile(args.docx):
print(f"Fehler: Datei nicht gefunden: {args.docx}", file=sys.stderr)
sys.exit(1)
os.makedirs(OUT, exist_ok=True)
print(f"Lese: {args.docx}")
litters, animals = extract(args.docx)
litters_path = os.path.join(OUT, "docx_litters.json")
animals_path = os.path.join(OUT, "docx_animals.json")
with open(litters_path, "w", encoding="utf-8") as f:
json.dump(litters, f, ensure_ascii=False, indent=2)
with open(animals_path, "w", encoding="utf-8") as f:
json.dump(animals, f, ensure_ascii=False, indent=2)
# Stats
named = sum(1 for a in animals if a["name"])
with_owner = sum(1 for a in animals if a["owner"])
with_death = sum(1 for a in animals if a["deathDate"])
with_abgabe = sum(1 for a in animals if a["abgabeDate"])
print(f"Würfe: {len(litters)}")
print(f"Tiere: {len(animals)} (benannt: {named})")
print(f" mit Abnehmer: {with_owner}")
print(f" mit Abgabe-Dat: {with_abgabe}")
print(f" mit Tod-Datum: {with_death}")
print(f"Ausgabe: {OUT}")
if __name__ == "__main__":
main()

View File

@@ -280,6 +280,36 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel")) check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell")) check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
_json.dump({"resolutions": [
# Valid decision (genotype parses OK) -> should be applied
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
"genotype": "BLÖDSINN!!!", "source": "test"},
]}, open(dec_cr10, "w", encoding="utf-8"))
merged_cr10 = [
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
]
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
check("CR-10: valid decision genotype is applied (A-locus updated)",
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
check("CR-10: malformed decision still un-quarantines the animal",
merged_cr10[1].get("conflict") is False)
check("CR-10: malformed decision adds a decisionWarning",
bool(merged_cr10[1].get("decisionWarnings")))
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
try: os.remove(dec_cr10)
except OSError: pass
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' --- # --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen. # Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so # The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so

View File

@@ -0,0 +1,77 @@
"""Tests for extract_docx.py — run: python test_extract_docx.py"""
import sys
import os
# Require the docx to exist; skip if not present (CI won't have it)
DOCX = os.path.join(r"C:\Users\gulum\dev",
"Wurfchronik der Kleinen Chaoten im Detail.docx")
SKIP = not os.path.isfile(DOCX)
import extract_docx as ed
failed = 0
def check(name, cond):
global failed
print(("ok: " if cond else "FAIL: ") + name)
if not cond:
failed += 1
# --- _norm_dob ---
check("norm_dob 2-digit year", ed._norm_dob("12.09.21") == "12.09.2021")
check("norm_dob 4-digit year", ed._norm_dob("07.04.2019") == "07.04.2019")
check("norm_dob empty", ed._norm_dob("") == "")
# --- _LAST_DATE_RE ---
check("last date: simple", ed._LAST_DATE_RE.findall("07.04.2019") == ["07.04.2019"])
check("last date: dual-day", ed._LAST_DATE_RE.findall("16./17.03.2021") == ["17.03.2021"])
check("last date: dual-month", ed._LAST_DATE_RE.findall("31.05/*01.06.2023") == ["01.06.2023"])
# --- _is_internal ---
check("ZT is internal", ed._is_internal("ZT"))
check("BLEIBT is internal", ed._is_internal("BLEIBT"))
check("VG: is internal", ed._is_internal("VG: Partner"))
check("real name not internal", not ed._is_internal("Marion Teichmann"))
# --- LITTER_RE ---
cases = [
("D19-Wurf *07.04.2019Von: Xhemile gen. Chanel v.d. Kleinen Chaoten & Omero v.d. Kleinen Chaoten WS: 2/4Notiz:", "2/4", "07.04.2019"),
("-Wurf *16./17.03.2021Von: Victoria Welby v.d. Kleinen Chaoten & Patch v.d. Kleinen Chaoten WS: /5Notiz:", "/5", "16./17.03.2021"),
("S22-Wurf *31.05/*01.06.2023Von: Velvet v.d. Kleinen Chaoten & Vance Sohn v.d. Kleinen ChaotenWS: 3/3", "3/3", "31.05/*01.06.2023"),
("Q21-Wurf *21.03.2022Von: Belica gen. Emi v.d. Kleinen Chaoten & Zac gen. Action v.d. Kleinen ChaotenWS: 2/4Notiz:", "2/4", "21.03.2022"),
]
for para, expected_ws, _ in cases:
m = ed.LITTER_RE.search(para)
ws = m.group(5).replace(" ", "") if m else None
check(f"LITTER_RE matches: {para[:50]}...", ws == expected_ws)
if SKIP:
print("(Skipping live-docx tests: file not found)")
else:
litters, animals = ed.extract(DOCX)
check("93 litters extracted", len(litters) == 93)
check("All litters have wsCode", all(l["wsCode"] for l in litters))
check("All litters have dob", all(l["dob"] for l in litters))
check(">200 named animals", len(animals) >= 200)
check(">150 animals with owner", sum(1 for a in animals if a["owner"]) >= 150)
check(">20 animals with death date", sum(1 for a in animals if a["deathDate"]) >= 20)
# Verify first litter
d19 = next((l for l in litters if l["litterId"] == "D19-Wurf"), None)
check("D19-Wurf found", d19 is not None)
check("D19-Wurf dob correct", d19 and d19["dob"] == "07.04.2019")
check("D19-Wurf wsCode = 2/4", d19 and d19["wsCode"] == "2/4")
check("D19-Wurf mother contains Xhemile", d19 and "Xhemile" in d19["motherName"])
# Verify Eddie in animals
eddie = next((a for a in animals if a["name"] == "Eddie" and a["wsCode"] == "2/4"), None)
check("Eddie found in D19-Wurf", eddie is not None)
check("Eddie gender=male (Zobel* suffix)", eddie and eddie["gender"] == "male")
check("Eddie abgabeDate", eddie and eddie["abgabeDate"] == "12.09.2021")
# Flash death date
flash = next((a for a in animals if a["name"] == "Flash" and a["wsCode"] == "3/3"), None)
check("Flash death date extracted", flash and flash["deathDate"] == "16.09.2023")
check("Flash death cause extracted", flash and "Tumor" in flash["deathCause"])
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)
print("\nALL PASS")