Compare commits

..

29 Commits

Author SHA1 Message Date
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
69ffe58d6b UI-POLISH-2: '(ohne Namen)' Platzhalter in Stammbaum, Vertrag-Wizard, Wurf-Formular
- StammbaumPage: PedigreeCard-Name + PrintCell-Name + beide titleFor()-Aufrufe → g.name || nameless
- VertragWizardPage: wizard-pick__name + join(', ')-Tierliste → g.name || nameless
- WurfFormPage: fatherName/motherName in Prefill (nameById.get || nameless) + Picker-Auswahl (g.name || nameless)

e2e: neuer Test stammbaum.spec — namenloser Ahne zeigt Platzhalter in Karte + Titel

Gate: vitest 82/82, e2e 148/148, tsc clean
2026-06-06 17:53:24 +02:00
d7c0698c44 Merge feature/form-fields: Herkunft (originBreeder) + 'Gehoerlos' (isDeaf) im Tier-Formular editierbar
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 54s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilFormPage: originBreeder-Freitextfeld + isDeaf-Tristate-Select (Ja/Nein/Unbekannt),
round-trip laden/speichern. types.ts isDeaf, de.ts Labels. +4 e2e round-trip. Komponiert
mit CR-2 PATCH-Semantik (ausgelassene Felder bleiben). vitest 82, e2e 146, build clean. Kein EF.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:47:57 +02:00
db49150206 FORM-FIELDS-1: originBreeder + isDeaf in GerbilFormPage
- types.ts: isDeaf?: boolean|null in Gerbil + CreateGerbil (UpdateGerbil erbt via Partial)
- de.ts: fields.originBreeder, fields.isDeaf; form.isDeafUnknown/Yes/No
- GerbilFormPage: originBreeder Freitext-Input (neben originContactId-Dropdown); isDeaf Tristate-Select (Unbekannt/Ja/Nein); round-trip load→display→save

e2e: 2 neue Tests (originBreeder Freitext + isDeaf Tristate round-trip)

Gate: vitest 82/82, e2e 146/146, tsc clean
Rebase auf Pams CR-2 folgt nach deren Merge.
2026-06-06 17:47:25 +02:00
f6a4025d71 Merge feature/fix-cr2-partial-update: P0 BLOCKER — PUT /gerbils PATCH-Semantik (kein Datenverlust mehr)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CR-2: Apply() behaelt ausgelassene Felder (?? g.X fuer alle); Name/Gender nullable;
ForSale-Toggle + Charakterbogen-Save + Edit-Form nullen keine anderen Felder mehr.
+4 Regressionstests (PartialUpdateTests.cs). Gate 137/137, ef has-pending=No, kein Schema-Change.
[god-QA: backend-only, disjunkt zu ui-polish]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:41:32 +02:00
8175b150f7 CR-2 FIX: PUT /gerbils PATCH-Semantik — partielle Updates zerstoeren keine Felder mehr
GerbilInput.Name + Gender auf nullable geaendert. Apply() wendet fuer alle Felder
?? g.X an (omittierte/null Felder behalten den DB-Wert). g.Name aus dem PUT-Handler
in Apply() verschoben. Verhindert stillen Datenverlust bei partiellen Frontend-Calls
(ForSale-Toggle schickt nur {status}, Charakterbogen-Save schickt nur {traits,note}).

+4 Regressionstests (PartialUpdateTests): ForSale-Toggle, Charakterbogen-Save,
EditForm-ohne-Import-Felder, minimaler POST — alle grueen (137/137 gesamt).
2026-06-06 17:40:15 +02:00
319aca7608 Merge feature/ui-polish: namenlose Tiere Platzhalter '(ohne Namen)' + Leerwert-'—'-Konsistenz
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
- de.ts nameless + GerbilsPage/GerbilDetailPage/AnimalPicker zeigen '(ohne Namen)' statt leer
- causeOfDeath/notes via || null → Rows zeigen '—' konsistent (Herkunft war schon korrekt)
- +4 e2e (Platzhalter Liste+Detail x2 Viewports). vitest 82, e2e 130, build clean. Kein EF.
[god-QA: disjunkt zu qa-regression+import-backfill-Merges]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:39:15 +02:00
4d9e901139 Merge feature/import-backfill: toleranter KC-Matcher + Herkunft-Backfill (fill-NULL) + Farbschlag-Zaehler
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
- extract.py: norm_zucht-Regex-Fix (trailing \b nach '.'), is_clan_zucht() — alle KC-Schreibvarianten → canon 'kleinechaote'
- ImportService: Herkunft-Backfill (OriginBreeder NULL+resident → aus Eltern, sonst 'Zucht der Kleinen Chaoten'); fixt C bei naechstem Import-execute; ueberschreibt nie non-null
- FarbschlagWouldRebackfill no-op-Zaehler (Overwrite-Pfad gated auf Julian A/B/C)
- Keine Schema-Aenderung. Gate 133/133 C# + 57/57 python, ef has-pending=No
[god-QA: disjunkt zu qa-regression-Merge; Herkunft-Backfill laeuft nur bei Import-execute = REIMPORT-3 supervised]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:58 +02:00
01781f23d6 Merge feature/qa-regression: e2e-Regressionsnetz fuer gehege/wurf-link/herkunft
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 53s
gehege-regression.spec.ts (12 Tests, 6x2 Viewports): /becken→/gehege Redirect (auch /neu),
Nav zeigt Gehege/kein Becken/kein Start, '/' → /rennmaeuse, 404-Link landet auf Tierliste.
WURF-LINK + Herkunft waren bereits in tiere.spec abgedeckt. e2e-only, kein EF.
[god-QA: 0 behind main, single new file]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:32:28 +02:00
55c298ad1f UI-POLISH-1: namenlose Tiere + Leerwert-Konsistenz
- de.ts: nameless = '(ohne Namen)' (neuer Platzhalter-String)
- GerbilsPage: g.name || t.nameless in Karten-Name + Checkbox-Aria
- GerbilDetailPage: g.name || nameless im h2-Header; g.causeOfDeath/notes via || null (leer-string-sicher wie Herkunft)
- AnimalPicker: g.name || nameless im Picker-Button

e2e: +1 nameless-stub in mock-data; 2 neue Tests (Liste + Detail-Header)

Gate: vitest 82/82, e2e 130/130, tsc clean
2026-06-06 17:32:26 +02:00
e1e088f041 QA-REGRESSION-1: GEHEGE-RENAME Regressions-Netz (Redirect /becken→/gehege, Nav-Negativ, Landing /rennmaeuse) 2026-06-06 17:30:31 +02:00
358e30db1f IMPORT-BACKFILL: toleranter KC-Matcher + Herkunft-Backfill + FarbschlagWouldRebackfill-Zaehler
1) TOLERANTER KC-MATCHER (extract.py norm_zucht): trailing \b nach '.' greift nicht
   zwischen two non-word-chars (z.B. nach '.' vor ' '). Fix: r\bv\.\s?d\. (kein \b
   am Ende). Alle Schreibvarianten ('Zucht der Kleinen Chaoten', 'kleinen Chaoten',
   'v.d. Kleinen Chaoten', '[ZdkC]') -> canon 'kleinechaote'. +is_clan_zucht() helper.
   +11 Python-Tests (Regression + neue Varianten). FIX-1-Entscheidungs-Matching
   unberuehrt (canon_pair loest v.d. bereits via split_name_zucht).

2) HERKUNFT-BACKFILL (ImportService.cs, fill-NULL-only): POST-Sweep ueber alle
   DB-Tiere mit OriginBreeder==null && IsResident==true. Leitet OriginBreeder aus
   dem Elterntier (FatherId/MotherId -> OriginBreeder) ab; Fallback 'Zucht der
   Kleinen Chaoten'. NIEMALS ueberschreibt nicht-leeren OriginBreeder. Trocken-Lauf
   zaehlt HerkunftBackfilled; Execute schreibt. Fixt Tier 'C' (OriginBreeder null).

3) FARBSCHLAG-WOULD-REBACKFILL (no-op Zaehler): zaehlt bereits-importierte Tiere,
   bei denen der aktuelle Extraktor einen anderen Farbschlag liefert als in der DB.
   Kein Overwrite (Sicherheitsmechanismus ausstehend auf god/Julian-Freigabe);
   Optionen A/B/C an god gemeldet, Empfehlung: Option B (Timestamp-basiert).

4) ResidencySummary um HerkunftBackfilled + FarbschlagWouldRebackfill erweitert.
   ImportReport-Notizen fuer beide neuen Zaehler.

GATE: 133/133 C#-Tests, 57 Python-Tests, ef has-pending=No.
2026-06-06 17:29:02 +02:00
0b9b03eb2d Merge feature/wurf-link: Wurf auf Tier-Detailseite als Hyperlink
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 52s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 9s
GerbilDetailPage: Wurf-Feld → <Link to=/wuerfe/{litterId}> wenn litterId+Name aufloesbar,
sonst plain '—' (kein toter Link). +e2e tiere.spec (Link klickbar, navigiert zur Wurf-Detail).
Frontend-only, kein EF. [god-QA: disjunkt zu gehege-rename]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:01:26 +02:00
d4c9be23c8 Merge feature/gehege-rename: Becken→Gehege + Terrarium-Icon + Start-Nav raus, /rennmaeuse = Landing
- UI-Begriff 'Becken' → 'Gehege' (de.ts, Becken-Pages, Abgabe/Hilfe), Route /becken→/gehege+Redirect
- Nav-Icon Badewanne → Terrarium; 'Start'-Nav-Eintrag entfernt
- Index-Route '/' → Navigate /rennmaeuse (Landing Page), HomePage.tsx geloescht
- e2e navigation/becken-kontakte angepasst
Frontend-only, kein EF. [god-QA: disjunkt zu main-Commits, Home-Assertions nur in navigation.spec (selbst gefixt), live-data.spec referenziert kein '/']

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:01:09 +02:00
f6bfc0ebab WURF-LINK-Addendum: Herkunft-Zeile zeigt originBreeder + Kontaktlink
- originContactId gesetzt → Link /kontakte/{id} mit Kontaktname (Priorität)
- Sonst originBreeder (Freitext) gesetzt → plain text
- Sonst '—' (bisheriges Fallback)
Behebt: 74 Tiere mit originBreeder sahen '—' obwohl Daten vorhanden

e2e: 2 neue Tests (originBreeder-Text + Kontaktlink)

Gate: vitest 82/82, e2e 126/126, build+tsc clean
2026-06-06 17:00:32 +02:00
3160b66697 WURF-LINK: Wurf-Feld auf Tier-Detailseite als Link zur Wurf-Detailseite (/wuerfe/{id})
- GerbilDetailPage: Wurf-Feld zeigt Link wenn litterId + Name auflösbar; sonst bisheriger '—'-Fallback (kein toter Link)
- e2e: neuer Test verifiziert Link sichtbar + Navigation zu Wurf-Detail

Gate: vitest 82/82, e2e 122/122, build+tsc clean
2026-06-06 16:56:54 +02:00
39767cef0f Merge feature/qa-livedata: QA-LIVE-DATA Live-Test-Suite (50/50 grün)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 51s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
live-data.spec.ts (neu, 44 Tests: 22 Phone 390px + 22 Desktop) — tiefer QA-Lauf
gegen echte 325 Tiere/865 Wuerfe; 0 JS-Errors, 0 Page-Overflow, app uebergabebereit.
M-1+M-2 Fix: deterministischer Wurf-ID-Link statt nth, fixtures mainNavigation strict.
Test-only (e2e), kein App-/Backend-/EF-Code. [god-QA: Branch direkt vor main, M-2-aria-label verifiziert]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:50:45 +02:00
162a0fafb1 GEHEGE-RENAME Addendum: /rennmaeuse = Landing Page, 'Start'-Nav entfernt
- AppShell.tsx: Start-Eintrag (/) aus PRIMARY entfernt; /rennmaeuse ist
  nun erste Kachel und Landing Page
- App.tsx: index-Route → <Navigate to="/rennmaeuse" replace>;
  HomePage-Import entfernt
- HomePage.tsx: gelöscht (nicht mehr referenziert, via git wiederherstellbar)
- de.ts: nav.home + pages.home entfernt (nur von AppShell/HomePage genutzt)
- e2e/navigation.spec.ts: HomePage-Assertions → de.pages.gerbils.title
Gate: build ✓  eslint ✓  vitest 82/82 ✓  e2e 120/120 ✓
2026-06-06 16:49:21 +02:00
c26ac5c6dd QA-LIVE-DATA: Fix M-1+M-2 — fixtures.ts mainNavigation strict, live.spec.ts deterministischer Wurf-ID-Link 2026-06-06 16:46:12 +02:00
2bbdfe2b71 GEHEGE-RENAME: UI-Begriff Becken → Gehege + Route /gehege + Icon 🏜️
- de.ts: alle nutzer-sichtbaren 'Becken'-Strings → 'Gehege' (nav, fields,
  titles, buttons, validation, delete, hilfe sections)
- AppShell.tsx: route /becken → /gehege, icon 🛁🏜️ (Wüsten-Habitat)
- App.tsx: <Route path="gehege"> + <Route path="becken/*"> redirect (bewahrt
  Bookmarks; BeckenRedirect leitet Unterseiten 1:1 weiter)
- BeckenPage/DetailPage/FormPage: interne Links /becken → /gehege
- HilfePage.tsx: hartcodierte 'Becken'-Prosa → 'Gehege'
- e2e/becken-kontakte.spec.ts: goto('/becken') → goto('/gehege')
Gate: build ✓  eslint ✓  vitest 82/82 ✓  e2e 120/120 ✓
2026-06-06 16:45:29 +02:00
2c3f342905 QA-LIVE-DATA: Playwright live-data suite (44 Tests, 22 Phone + 22 Desktop, alle gruen) 2026-06-06 16:38:51 +02:00
b165916dfb docs(HUMANQUESTION): Zahlen auf Re-Import #2.5 aktualisiert (325 Tiere/865 Wuerfe/138 Fotos); Enya/Ella/Zac geladen -> nur noch D6 (5 Konflikte) offen
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
CI / Backend Tests (.NET) (push) Successful in 54s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:19:40 +02:00
797b38df34 docs(HUMANQUESTION): A5b erledigt - Gitea Actions LIVE (Runner aktiv, Tests gruen); neu A5b2: REGISTRY_USER/REGISTRY_TOKEN Secrets fuer Docker-Push-Job
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m31s
CI / Docker Build & Push (push) Failing after 8s
2026-06-06 16:05:03 +02:00
c34d63e0fd Merge feature/web-6a: Oeffentliche Webseite launch-ready (mobile-first Design, Abgabetiere-Gruppenkarten, Grundseiten-Seeds) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 16:01:27 +02:00
a2bdf9626c Merge feature/ux-mobile-2: Genotyp-Tabellen horizontal scrollbar (overflow-x-Wrapper + nowrap) + app-main overflow-x:hidden gegen Page-Overflow [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 54s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 10s
2026-06-06 15:43:43 +02:00
0d4b560df8 Merge feature/seed-hell: Backend-Seed 61->66 append-only (IDs 62-66, bestehende ID->Name-Bindung unveraendert, Drift-Regressionstest) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:42:36 +02:00
8dfad32918 SEED-HELL v2: 5 CP-*-Hell ans Ende haengen (IDs 62-66, kein ID-Name-Drift)
Erster Ansatz (Rename-in-Place-Shift) wuerde 17 Hand-zugewiesene
Gerbil.ColorVarietyId-FKs still verfaelschen (1 Tier auf CP-Fuchs live
bestaetigt). Stattdessen: 5 neue Eintraege ans ENDE gehaengt (IDs 62-66),
die 61 bestehenden Eintraege unveraendert.

Migration ReseedColorVarietiesGen3g: NUR 5 InsertData (IDs 62-66),
kein UpdateData, kein DeleteData, keine FK-Drift moeglich.

Neuer Test SeedGen3g_existing_varieties_preserve_id_name_binding:
asserts CP-Fuchs (ID 58) bleibt CP-Fuchs, alle 5 neuen IDs 62-66
haben korrekte Namen, Gesamtzahl = 66.

Gate: 127/127 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:40:57 +02:00
1ee176a24a SEED-HELL: Backend-ColorVariety-Seed 61->66 (GEN-3g CP-*-Hell)
Kevin GEN-3g fuegt 5 CP-*-Hell-Varianten ein (hell = cchmch):
CP-Agouti-Hell, CP-Silberagouti-Hell, CP-Algierfuchs-Hell,
CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.

Eintraege 0-53 (PEW..CP-Agouti) unveraendert. Eintraege 54-61 werden
durch UpdateData in den bestehenden IDs 55-61 umgeschrieben (EF-Rename-
in-Place-Shift): CP-Silberagouti/Algierfuchs/Polarfuchs/Fuchs/Fuchs-Hell/
Blaufuchs/Orangeschimmel ruecken um +1 bis +4 Positionen. 5 echte Neu-
Eintraege als InsertData in IDs 62-66.

Migration: 7 UpdateData + 5 InsertData, keine DeleteData, kein Datenverlust
ausser theoretischer Gerbil.ColorVarietyId-FK-Drift fuer die CP-Varianten
(ON DELETE SET NULL + Re-Import re-matcht per Name).

Gate: 126/126 C#-Tests, has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:36:16 +02:00
97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav,
  site-footer-nav mit Impressum/Datenschutz-Links)
- AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung,
  Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen
  mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote)
- SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote
  erweitert
- Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als
  Platzhalter-Seiten (footer-only, kein navOrder-Eintrag)
- SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests)
- Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests
  (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier,
   Impressum/Datenschutz, Footer-Links)
Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
2026-06-06 15:15:54 +02:00
44 changed files with 4193 additions and 218 deletions

View File

@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
public async Task Snapshot_has_site_nav_and_seeded_pages()
{
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var root = doc.RootElement;
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
var pages = root.GetProperty("pages").EnumerateArray().ToList();
Assert.Equal(6, pages.Count);
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
Assert.Equal(8, pages.Count);
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
var listBlock = abg.GetProperty("blocks").EnumerateArray()

View File

@@ -545,6 +545,32 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
{
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
Assert.NotNull(cpFuchs);
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
// New entries at IDs 62-66 exist with correct names
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
// Total count is exactly 66
Assert.Equal(66, db.ColorVarieties.Count());
}
[Theory]
[InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)]

View File

@@ -0,0 +1,143 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GerbilManager.Tests;
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
/// send partial bodies — all other fields must be preserved unchanged.</summary>
public class PartialUpdateTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
// ------------------------------------------------------------------ helpers
private static Guid ExtractId(string json) =>
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
private static string? GetStr(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private static string? GetNested(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
/// <summary>Create a fully-populated gerbil and return its id.</summary>
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
{
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
{
name,
gender = "female",
genotype = "aa CC DD ee GG PP spsp rere",
originBreeder = "Zucht der Kleinen Chaoten",
isDeaf = false,
characterTraits = new[] { "neugierig", "zutraulich" },
characterNote = "Liebling der Familie",
notes = "Eine Notiz",
externalRef = "ext-001",
}));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
}
// ------------------------------------------------------------------ tests
[Fact]
public async Task ForSale_toggle_does_not_wipe_other_fields()
{
// Arrange: create a fully-populated gerbil
var id = await CreateFullGerbil("ForSale-Tier");
// Act: send only { status: 'ForSale' } — exactly what the detail page does
var put = await _client.PutAsync($"/gerbils/{id}",
JsonContent.Create(new { status = "ForSale" }));
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
// Assert: re-read and check all fields survive
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("ForSale", GetStr(json, "status"));
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.Equal("ext-001", GetStr(json, "externalRef"));
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}
[Fact]
public async Task CharacterSave_does_not_wipe_other_fields()
{
// Arrange
var id = await CreateFullGerbil("Charakter-Tier");
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
{
characterTraits = new[] { "handzahm" },
characterNote = "Neue Notiz",
}));
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
// Assert: name / genotype / originBreeder / status / externalRef all intact
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("Active", GetStr(json, "status"));
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
Assert.Equal("ext-001", GetStr(json, "externalRef"));
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
}
[Fact]
public async Task EditForm_omitting_import_fields_keeps_them()
{
// Arrange: gerbil with import-set fields
var id = await CreateFullGerbil("EditForm-Tier");
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
{
name = "EditForm-Tier (umbenannt)",
gender = "female",
genotype = "aa CC DD ee GG PP spsp rere",
notes = "Aktualisierte Notiz",
}));
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
// Assert: name updated, import fields survive
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
Assert.Equal("ext-001", GetStr(json, "externalRef"));
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}
[Fact]
public async Task Create_with_minimal_body_uses_safe_defaults()
{
// A minimal POST (only name) must still produce a valid gerbil
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Active", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}
}

View File

@@ -69,10 +69,11 @@ public class SiteRendererTests
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
};
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
private static JsonObject AbgabetiereBlock(string intro,
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
{
var animalArr = new JsonArray();
foreach (var (name, farbe, group, photo) in animals)
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
{
var photos = new JsonArray();
if (photo is not null) photos.Add(photo);
@@ -81,6 +82,9 @@ public class SiteRendererTests
["name"] = name,
["farbschlag"] = farbe,
["group"] = group,
["gender"] = gender,
["dateOfBirth"] = dob,
["characterNote"] = note,
["photos"] = photos,
["aiSaleText"] = (JsonNode?)null,
});
@@ -92,6 +96,7 @@ public class SiteRendererTests
};
}
// ── Tests ────────────────────────────────────────────────────────────────
[Fact]
@@ -211,16 +216,52 @@ public class SiteRendererTests
}
[Fact]
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Krümel", html);
Assert.Contains("CP-Agouti", html);
Assert.Contains("Großbecken", html);
Assert.Contains("/photos/files/abc.jpg", html);
Assert.Contains("cms-animal-card", html);
Assert.Contains("cms-group-card", html);
}
[Fact]
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Weibchen", html);
Assert.Contains("Verfügbar", html);
Assert.Contains("cms-badge--free", html);
}
[Fact]
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("März 2024", html);
Assert.Contains("Sehr neugierig und verspielt.", html);
}
[Fact]
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Einzelkind", "Zobel", null, null, "male", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Einzelkind", html);
Assert.Contains("Männchen", html);
Assert.Contains("cms-group-card", html);
}
[Fact]
@@ -283,6 +324,44 @@ public class SiteRendererTests
Assert.Equal(7, files.Count);
}
[Fact]
public void Render_impressum_and_datenschutz_pages_are_rendered()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("impressum/index.html"));
Assert.True(files.ContainsKey("datenschutz/index.html"));
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
}
[Fact]
public void Footer_contains_impressum_and_datenschutz_links()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var startHtml = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("impressum/index.html", startHtml);
Assert.Contains("datenschutz/index.html", startHtml);
// links in footer, not nav
Assert.Contains("site-footer-link", startHtml);
}
[Fact]
public void Footer_links_use_correct_relative_path_from_subpage()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
// subpage needs ../ prefix for footer links
Assert.Contains("../impressum/index.html", kontaktHtml);
Assert.Contains("../datenschutz/index.html", kontaktHtml);
}
// ── Markdown tests ───────────────────────────────────────────────────────
[Theory]

View File

@@ -193,6 +193,15 @@ public class ApplicationContext : DbContext
(6, "kontakt", "Kontakt"),
};
// WEB-6a: footer-only legal pages (not in main nav)
(int n, string Slug, string Title, string BodyMd)[] legalPages =
{
(7, "impressum", "Impressum",
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
(8, "datenschutz", "Datenschutz",
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
};
var pageRows = new List<Page>();
var blockRows = new List<Block>();
foreach (var p in pages)
@@ -211,6 +220,12 @@ public class ApplicationContext : DbContext
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
});
foreach (var lp in legalPages)
{
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
}
modelBuilder.Entity<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows);
@@ -231,6 +246,10 @@ public class ApplicationContext : DbContext
/// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder)
{
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
// SortOrder for new entries is appended; Kevin's frontend catalog handles
// the interleaved display order via its own sortOrder values.
(string Name, string Genotype)[] catalog =
{
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
@@ -294,6 +313,12 @@ public class ApplicationContext : DbContext
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
};
var rows = new ColorVariety[catalog.Length];

View File

@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
private static string RenderAbgabetiereList(JsonObject d)
{
var intro = Str(d["intro"]);
var animals = d["animals"] as JsonArray ?? [];
var animals = (d["animals"] as JsonArray ?? [])
.OfType<JsonObject>().ToList();
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
if (!string.IsNullOrWhiteSpace(intro))
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
if (animals.Count == 0)
{
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
sb.Append("</section>\n");
return sb.ToString();
}
else
// Group by Becken name; null group → solo card keyed by animal name
var groups = animals
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
.ToList();
sb.Append(" <div class=\"cms-group-list\">\n");
foreach (var grp in groups)
{
sb.Append(" <div class=\"cms-animal-grid\">\n");
foreach (var node in animals)
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
var groupName = isSolo ? null : grp.Key;
var memberList = grp.ToList();
// Determine group gender label
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
string genderLabel = genders.Count == 1
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
sb.Append(" <article class=\"cms-group-card\">\n");
sb.Append(" <div class=\"cms-group-header\">\n");
if (!string.IsNullOrEmpty(groupName))
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
var metaParts = new List<string>();
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
sb.Append(" </div>\n");
// Gallery: first photo of each animal in the group
var gallery = memberList
.Select(a => (
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
name: Str(a["name"]) ?? ""))
.Where(x => !string.IsNullOrEmpty(x.url))
.ToList();
if (gallery.Count > 0)
{
if (node is not JsonObject a) continue;
var name = H(Str(a["name"]) ?? "");
var farbe = H(Str(a["farbschlag"]) ?? "");
var group = Str(a["group"]);
var saleText = Str(a["aiSaleText"]);
var photos = a["photos"] as JsonArray ?? [];
sb.Append(" <article class=\"cms-animal-card\">\n");
// Profile photo (first photo)
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
if (!string.IsNullOrEmpty(firstPhoto))
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
sb.Append(" <div class=\"cms-animal-info\">\n");
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
if (!string.IsNullOrEmpty(farbe))
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
if (!string.IsNullOrWhiteSpace(group))
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
if (!string.IsNullOrWhiteSpace(saleText))
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
sb.Append(" <div class=\"cms-group-gallery\">\n");
foreach (var (url, name) in gallery)
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
sb.Append(" </div>\n");
sb.Append(" </article>\n");
}
sb.Append(" </div>\n");
// Per-animal detail rows
sb.Append(" <ul class=\"cms-animal-list\">\n");
foreach (var a in memberList)
{
var name = H(Str(a["name"]) ?? "");
var farbe = Str(a["farbschlag"]);
var dob = Str(a["dateOfBirth"]);
var note = Str(a["characterNote"]);
var saleText = Str(a["aiSaleText"]);
sb.Append(" <li class=\"cms-animal-row\">\n");
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
var details = new List<string>();
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
if (details.Count > 0)
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
var personalityText = note ?? saleText;
if (!string.IsNullOrWhiteSpace(personalityText))
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
sb.Append(" </li>\n");
}
sb.Append(" </ul>\n");
sb.Append(" </article>\n");
}
sb.Append(" </div>\n");
sb.Append("</section>\n");
return sb.ToString();
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
</main>
<footer class="site-footer">
<p>&copy; {H(SiteName)} Mongolische Rennmäuse</p>
<nav class="site-footer-nav" aria-label="Rechtliches">
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
</nav>
</footer>
</body>
</html>
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
["abgabebedingungen"] = "Abgabebedingungen",
["farben-genetik"] = "Farben & Genetik",
["kontakt"] = "Kontakt",
["impressum"] = "Impressum",
["datenschutz"] = "Datenschutz",
};
var sb = new StringBuilder();
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
internal static string SiteCss() => """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-bg: #fdf8f3;
--color-bg: #fdf7f0;
--color-surface: #fff;
--color-text: #2c2c2c;
--color-muted: #6b6b6b;
--color-accent: #c0392b;
--color-border: #e0d8d0;
--font-body: system-ui, sans-serif;
--max-w: 860px;
--color-surface-warm: #fef9f4;
--color-text: #2a2118;
--color-muted: #7a6d62;
--color-accent: #b5331a;
--color-accent-hover: #8f2815;
--color-accent-soft: #fdf0ed;
--color-border: #e8ddd4;
--color-badge-free: #2d7a4a;
--color-badge-free-bg: #e6f4ec;
--font-body: Georgia, "Times New Roman", serif;
--font-ui: system-ui, -apple-system, sans-serif;
--max-w: 900px;
--radius: 10px;
}
body {
font-family: var(--font-body);
background: var(--color-bg);
color: var(--color-text);
line-height: 1.65;
line-height: 1.7;
font-size: 1rem;
}
a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
img { max-width: 100%; height: auto; display: block; }
p { margin-bottom: .75rem; }
/* ── Header ── */
.site-header {
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: .75rem 1rem;
border-bottom: 2px solid var(--color-border);
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem 1.5rem;
align-items: stretch;
gap: 0;
min-height: 56px;
}
.site-logo {
font-family: var(--font-body);
font-weight: 700;
font-size: 1.1rem;
font-size: 1.15rem;
color: var(--color-text);
white-space: nowrap;
}
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
.site-nav-link {
font-size: .9rem;
color: var(--color-muted);
padding: .2rem .4rem;
border-radius: 4px;
}
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: #fef0ee;
display: flex;
align-items: center;
padding-right: 1.5rem;
border-right: 1px solid var(--color-border);
text-decoration: none;
flex-shrink: 0;
}
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
.site-nav {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-left: .5rem;
}
.site-nav::-webkit-scrollbar { display: none; }
.site-nav-link {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
padding: .5rem .65rem;
border-radius: 6px;
white-space: nowrap;
transition: color .15s, background .15s;
}
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
.site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: var(--color-accent-soft);
font-weight: 600;
}
/* ── Main ── */
.site-main {
max-width: var(--max-w);
margin: 2rem auto;
padding: 0 1rem 3rem;
margin: 2.5rem auto;
padding: 0 1rem 4rem;
}
/* ── Footer ── */
.site-footer {
border-top: 1px solid var(--color-border);
padding: 1.5rem 1rem;
background: var(--color-surface);
border-top: 2px solid var(--color-border);
padding: 1.75rem 1rem;
text-align: center;
font-family: var(--font-ui);
font-size: .85rem;
color: var(--color-muted);
}
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
.site-footer-link:hover { color: var(--color-accent); }
/* ── CMS blocks ── */
.cms-heading { margin: 1.5rem 0 .5rem; }
h1.cms-heading { font-size: 1.8rem; }
h2.cms-heading { font-size: 1.4rem; }
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
h1.cms-heading { font-size: 2rem; margin-top: 0; }
h2.cms-heading { font-size: 1.5rem; }
h3.cms-heading { font-size: 1.2rem; }
.cms-richtext { margin: 1rem 0; }
.cms-richtext p { margin-bottom: .75rem; }
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
.cms-richtext li { margin-bottom: .3rem; }
.cms-image { margin: 1.5rem 0; }
.cms-image img { border-radius: 6px; }
.cms-richtext li { margin-bottom: .35rem; }
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
.cms-image { margin: 1.75rem 0; }
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.cms-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem;
margin: 1.5rem 0;
margin: 1.75rem 0;
}
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
.cms-contact {
font-style: normal;
font-family: var(--font-ui);
display: flex;
flex-direction: column;
gap: .4rem;
margin: 1rem 0;
gap: .5rem;
margin: 1.25rem 0;
background: var(--color-surface-warm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
}
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
/* ── Abgabetiere ── */
.cms-abgabe { margin: 1rem 0; }
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
/* ── Abgabetiere — group cards ── */
.cms-abgabe { margin: 1.25rem 0; }
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-animal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem;
}
.cms-animal-card {
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
.cms-group-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.cms-group-header {
padding: 1rem 1.25rem .75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem .75rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-warm);
}
.cms-group-name {
font-family: var(--font-body);
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
}
.cms-group-meta {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
display: flex;
align-items: center;
gap: .5rem;
}
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
.cms-animal-info { padding: .875rem; }
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
@media (max-width: 480px) {
h1.cms-heading { font-size: 1.4rem; }
.cms-animal-grid { grid-template-columns: 1fr; }
/* availability badge */
.cms-badge {
display: inline-block;
font-size: .75rem;
font-weight: 600;
font-family: var(--font-ui);
padding: .2rem .55rem;
border-radius: 20px;
letter-spacing: .02em;
text-transform: uppercase;
}
.cms-badge--free {
background: var(--color-badge-free-bg);
color: var(--color-badge-free);
}
/* group photo strip */
.cms-group-gallery {
display: flex;
gap: .375rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: .75rem 1.25rem;
background: #f5ede4;
}
.cms-group-gallery::-webkit-scrollbar { display: none; }
.cms-group-photo {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
/* animal detail list */
.cms-animal-list {
list-style: none;
padding: .75rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: .875rem;
}
.cms-animal-row {
display: flex;
flex-direction: column;
gap: .15rem;
padding-bottom: .875rem;
border-bottom: 1px solid var(--color-border);
}
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
.cms-animal-name {
font-family: var(--font-body);
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.cms-animal-detail {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
}
.cms-animal-note {
font-style: italic;
font-size: .925rem;
color: var(--color-text);
margin-top: .1rem;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.site-header { padding: 0 .75rem; min-height: 50px; }
.site-logo { font-size: 1rem; padding-right: 1rem; }
h1.cms-heading { font-size: 1.5rem; }
.site-main { margin-top: 1.5rem; }
.cms-group-photo { width: 110px; height: 110px; }
}
@media (min-width: 700px) {
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
}
""";

View File

@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
{
g.Id,
g.Name,
g.Gender,
g.DateOfBirth,
g.CharacterNote,
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
// group = Becken (enclosure) name — matches how the Abgabe composer
// groups ForSale animals by enclosure-mates (god ruling). null = single.
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
arr.Add(new JsonObject
{
["name"] = a.Name,
["gender"] = a.Gender.ToString(),
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
["characterNote"] = a.CharacterNote,
["farbschlag"] = a.Farbschlag,
["group"] = a.Group,
["photos"] = photoArr,

View File

@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
// Request DTOs -----------------------------------------------------------
public record GerbilInput(
string Name,
Gender Gender,
string? Name,
Gender? Gender,
GerbilStatus? Status,
Guid? LitterId,
Guid? OriginContactId,

View File

@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
if (string.IsNullOrWhiteSpace(input.Name))
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
Apply(g, input, isCreate: true);
db.Gerbils.Add(g);
await db.SaveChangesAsync();
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
});
// PUT /gerbils/{id}
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
{
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
g.Name = input.Name;
Apply(g, input, isCreate: false);
await db.SaveChangesAsync();
return TypedResults.NoContent();
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
// for fields the frontend omits. A non-null input value always wins (including explicit
// nulls — callers that want to clear a nullable field must send a full object; a
// dedicated PATCH endpoint can be added later if point-clear is needed).
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
{
g.Gender = i.Gender;
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
g.LitterId = i.LitterId;
g.OriginContactId = i.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId;
g.EnclosureId = i.EnclosureId;
g.ColorVarietyId = i.ColorVarietyId;
g.DateOfBirth = i.DateOfBirth;
g.DateOfDeath = i.DateOfDeath;
g.CauseOfDeath = i.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate;
g.Genotype = i.Genotype;
g.Notes = i.Notes;
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
g.IsDeaf = i.IsDeaf;
g.LitterId = i.LitterId ?? g.LitterId;
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
g.Genotype = i.Genotype ?? g.Genotype;
g.Notes = i.Notes ?? g.Notes;
g.ImportSource = i.ImportSource ?? g.ImportSource;
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
}

View File

@@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import
ResidencySummary? Residency = null);
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,

View File

@@ -137,12 +137,14 @@ namespace GerbilManagerWebAPI.Import
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
var existingRows = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
var gidByNameDob = new Dictionary<string, Guid>();
foreach (var g in existingRows)
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
var plan = new List<AnimalPlan>();
@@ -169,7 +171,8 @@ namespace GerbilManagerWebAPI.Import
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
}
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
@@ -460,6 +463,51 @@ namespace GerbilManagerWebAPI.Import
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
}
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
// Counts already-imported animals where the current extract matched a DIFFERENT
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
int farbschlagWouldRebackfill = plan.Count(p =>
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
// 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'.
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
int herkunftBackfilled = 0;
{
var needsHerkunft = await _db.Gerbils
.Where(g => g.OriginBreeder == null && g.IsResident)
.Select(g => new { g.Id, g.LitterId })
.ToListAsync();
foreach (var g in needsHerkunft)
{
herkunftBackfilled++;
if (execute)
{
// Prefer the OriginBreeder of an existing parent (father first, then mother).
string? derived = null;
if (g.LitterId is not null)
{
var parentIds = await _db.Litters
.Where(l => l.Id == g.LitterId)
.Select(l => new { l.FatherId, l.MotherId })
.FirstOrDefaultAsync();
if (parentIds?.FatherId is Guid fid)
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
if (derived is null && parentIds?.MotherId is Guid mid)
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
}
var row = await _db.Gerbils.FindAsync(g.Id);
if (row is not null && row.OriginBreeder is null)
row.OriginBreeder = derived ?? DefaultClanBreeder;
}
}
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
}
if (littersWithoutDate > 0)
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
@@ -469,6 +517,10 @@ namespace GerbilManagerWebAPI.Import
if (parentFksBackfilled > 0)
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
if (herkunftBackfilled > 0)
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
if (farbschlagWouldRebackfill > 0)
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
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).");
@@ -484,7 +536,8 @@ namespace GerbilManagerWebAPI.Import
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
}
private T? Load<T>(string file)
@@ -574,7 +627,8 @@ namespace GerbilManagerWebAPI.Import
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
private sealed record AnimalPlan(
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
Guid? CurrentColorVarietyId = null);
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddImpressumDatenschutzPages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Pages",
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
values: new object[,]
{
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
});
migrationBuilder.InsertData(
table: "Blocks",
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
values: new object[,]
{
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesGen3g : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmch DD EE GG PP spsp rere", "CP-Agouti-Hell", 61 },
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmch DD EE gg PP spsp rere", "CP-Silberagouti-Hell", 62 },
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmch DD ee GG PP spsp rere", "CP-Algierfuchs-Hell", 63 },
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmch DD ee gg PP spsp rere", "CP-Polarfuchs-Hell", 64 },
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmch DD efef GG PP spsp rere", "CP-Orangeschimmel-Hell", 65 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
}
}
}

View File

@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
Type = "AbgabetiereList"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000007"),
Data = "{\"text\":\"Impressum\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000070"),
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "RichText"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000008"),
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000080"),
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "RichText"
});
});
@@ -605,6 +637,41 @@ namespace GerbilManagerWebAPI.Migrations
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel",
SortOrder = 60
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
Name = "CP-Agouti-Hell",
SortOrder = 61
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
Name = "CP-Silberagouti-Hell",
SortOrder = 62
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
Name = "CP-Algierfuchs-Hell",
SortOrder = 63
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000065"),
CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere",
Name = "CP-Polarfuchs-Hell",
SortOrder = 64
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel-Hell",
SortOrder = 65
});
});
@@ -989,6 +1056,20 @@ namespace GerbilManagerWebAPI.Migrations
Slug = "kontakt",
Status = "Published",
Title = "Kontakt"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000007"),
Slug = "impressum",
Status = "Published",
Title = "Impressum"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000008"),
Slug = "datenschutz",
Status = "Published",
Title = "Datenschutz"
});
});

View File

@@ -10,9 +10,9 @@
_Stand: 2026-06-06._
Der Manager ist fertig und läuft. Die alten Daten sind importiert
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
---
@@ -24,7 +24,7 @@ Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
| 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 |
| 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) |
| 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) |
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) |
| 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)
@@ -61,7 +61,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
> ✅ **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`.
@@ -159,7 +159,7 @@ sagen, was ergänzt oder gestrichen werden soll:**
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| 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 8 in Quarantäne (Re-Import #2) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
| 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) |
---

View File

@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
test.describe('Becken', () => {
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
skipUnlessMock()
await page.goto('/becken')
await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click()
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
@@ -18,7 +18,7 @@ test.describe('Becken', () => {
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
skipUnlessMock()
await page.goto('/becken')
await page.goto('/gehege')
await page.getByRole('link', { name: /Großbecken/ }).click()
acceptNextDialog(page)
await page.getByRole('button', { name: tb.delete.action }).click()

View File

@@ -55,7 +55,7 @@ export function skipUnlessLive() {
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
*/
export async function gotoSection(page: Page, label: string) {
const nav = page.getByRole('navigation')
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// App noch nicht geladen (about:blank) -> erst zur Startseite
if (!(await nav.isVisible())) {
await page.goto('/')

View File

@@ -0,0 +1,90 @@
/**
* QA-REGRESSION-1: GEHEGE-RENAME Lock-in.
*
* Sichert das Verhalten von drei GEHEGE-RENAME-Änderungen ab, die leicht still
* brechen können (Redirects, Nav-Einträge, Landing-URL):
* (a) /becken/* → /gehege/* Redirect (bookmarks / externe Links)
* (b) Nav zeigt 'Gehege' + 🏜️, kein 'Becken', kein 'Start'-Eintrag
* (c) '/' (Index) landet auf /rennmaeuse (nicht mehr auf einer Startseite)
*
* Bereits abgedeckt (NICHT dupliziert):
* - gotoSection(de.nav.enclosures) navigiert zu Gehege-Heading: navigation.spec.ts:19-20
* - CRUD + 409 auf /gehege: becken-kontakte.spec.ts
* - Wurf-Link + Herkunft: tiere.spec.ts:101-128
*/
import { de, expect, test } from './fixtures'
// ── (a) /becken/* → /gehege/* Redirect ──────────────────────────────────────
test('GEHEGE-RENAME: /becken leitet auf /gehege weiter (bookmark-safe)', async ({ page }) => {
await page.goto('/becken')
// React Router BeckenRedirect navigiert → /gehege; Gehege-Heading muss sichtbar sein
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
await expect(page).toHaveURL(/\/gehege$/)
})
test('GEHEGE-RENAME: /becken/neu leitet auf /gehege/neu weiter (deep redirect)', async ({
page,
}) => {
await page.goto('/becken/neu')
// Neues-Gehege-Formular muss sichtbar sein, URL muss /gehege/neu sein
await expect(page.getByRole('heading', { name: de.pages.becken.form?.createTitle ?? de.pages.becken.newButton })).toBeVisible({ timeout: 6_000 })
await expect(page).toHaveURL(/\/gehege\/neu$/)
})
// ── (b) Nav: 'Gehege' ✓, 'Becken' ✗, 'Start' ✗ ──────────────────────────────
test('GEHEGE-RENAME: Nav hat "Gehege"-Eintrag mit Terrarium-Icon, kein "Becken"', async ({
page,
}) => {
await page.goto('/rennmaeuse')
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// Gehege-Link soll vorhanden sein (via „Mehr"-Blatt auf Phone oder direkt auf Desktop)
// Wir öffnen „Mehr" um sekundäre Links sichtbar zu machen
const moreBtn = nav.getByRole('button', { name: de.nav.more })
if (await moreBtn.isVisible()) {
await moreBtn.click()
}
await expect(nav.getByRole('link', { name: de.nav.enclosures })).toBeVisible()
// "Becken" als separater Nav-Eintrag darf nicht mehr existieren
await expect(nav.getByRole('link', { name: 'Becken', exact: true })).toBeHidden()
})
test('GEHEGE-RENAME: Nav hat keinen "Start"-Eintrag mehr (de.nav.home entfernt)', async ({
page,
}) => {
await page.goto('/rennmaeuse')
// Warten bis die Tierliste geladen ist, dann Nav prüfen
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
// 'Start' (de.nav.home wurde entfernt) darf kein Nav-Link mehr sein
await expect(nav.getByRole('link', { name: 'Start', exact: true })).toBeHidden()
})
// ── (c) '/' landet auf /rennmaeuse ──────────────────────────────────────────
test('GEHEGE-RENAME: "/" leitet auf /rennmaeuse weiter — keine Startseite mehr', async ({
page,
}) => {
await page.goto('/')
// URL muss /rennmaeuse sein (Navigate replace)
await expect(page).toHaveURL(/\/rennmaeuse$/)
// Tierliste-Heading sichtbar — nicht die alte Startseite
await expect(
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
).toBeVisible()
// Alten Willkommen-Text darf es nicht mehr geben (de.pages.home wurde entfernt)
await expect(page.getByText('Willkommen')).toBeHidden()
})
test('GEHEGE-RENAME: 404-Seite "Zur Startseite" landet weiterhin auf Tierliste', async ({
page,
}) => {
await page.goto('/diese-seite-gibt-es-nicht')
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
// Nach Klick auf "Zur Startseite" → '/' → redirect → /rennmaeuse
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await expect(page).toHaveURL(/\/rennmaeuse$/)
})

View File

@@ -0,0 +1,302 @@
/**
* QA-LIVE-DATA: Tiefe Begehung mit echten Import-Daten (325 Tiere, 865 Würfe, 138 Fotos).
* Läuft ausschließlich im LIVE-Modus (E2E_BASE_URL gesetzt).
*
* Bekannte Datengrundlage (Stand Re-Import #2.5):
* - Vance von den Kleinen Chaoten id=afdaff89-0274-4778-8f2f-3957a81bf58e (Resident, Stammbaum)
* - Girasol of Topolino id=bf20f105-2ea9-475f-89b7-d50e59fb7d3b (9 Fotos)
* - Litter 2026-02-10 id=a1db02cf-48ff-4f3a-8eab-33f2360c27f0 (beide Eltern gesetzt)
* - 204 Residents, 121 Externe, 15 namenlose Einträge
*/
import { de, expect, gotoSection, openFilterPanel, skipUnlessLive, test } from './fixtures'
const t = de.pages.gerbils
const tl = de.pages.litters
const td = t.detail
// ── 1. RENNMÄUSE-LISTE ───────────────────────────────────────────────────────
test('Live-Data: Tiere-Liste lädt 325 Einträge (Bestand-Default-Filter inklusive externe)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible()
const count = await rows.count()
// Alle 325 oder zumindest Paginierung der ersten Seite (>=20)
expect(count).toBeGreaterThanOrEqual(20)
// Count-Label sichtbar ("204 Tiere" für Bestand-Default-Filter oder "325 Tiere")
await expect(page.locator('p.muted').filter({ hasText: t.countLabel })).toBeVisible()
})
test('Live-Data: Bestand-Filter sichtbar + umschaltbar (Resident vs. Alle)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await openFilterPanel(page)
// Filter-Panel oder mindestens der "Nur Bestand"-Toggle ist sichtbar
const filterSection = page.locator('.filter-panel, [aria-label*="Filter"], .filter-panel__content')
await expect(filterSection.first()).toBeVisible({ timeout: 5_000 })
})
test('Live-Data: Suche nach "Vance" findet mindestens einen Treffer', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Vance')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(1)
// Name "Vance" soll in einem Ergebnis auftauchen
await expect(page.locator('.gerbil-row').filter({ hasText: 'Vance' }).first()).toBeVisible()
})
test('Live-Data: Suche nach "von den" (Namensmuster) liefert Treffer', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
await page.getByPlaceholder(t.searchPlaceholder).first().fill('von den')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(5)
})
test('Live-Data: Genotyp-Tabelle scrollt horizontal (kein Page-Overflow bei 390px)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row').first()).toBeVisible()
// Kein horizontaler Scroll auf body/html
const bodyOverflow = await page.evaluate(() => {
const body = document.body
const html = document.documentElement
return {
bodyScrollWidth: body.scrollWidth,
htmlScrollWidth: html.scrollWidth,
viewportWidth: window.innerWidth,
}
})
expect(bodyOverflow.bodyScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
expect(bodyOverflow.htmlScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
})
// ── 2. TIER-DETAIL ────────────────────────────────────────────────────────────
test('Live-Data: Girasol of Topolino Detail zeigt Fotos-Tab mit mindestens 9 Fotos', async ({
page,
}) => {
skipUnlessLive()
await page.goto('/rennmaeuse/bf20f105-2ea9-475f-89b7-d50e59fb7d3b')
await expect(page.getByRole('heading', { name: 'Girasol of Topolino' })).toBeVisible()
// Fotos-Tab anklicken
await page.getByRole('tab', { name: td.tabs.photos }).click()
// Mindestens ein Foto-Img sichtbar (9 Fotos importiert)
const photos = page.locator('img.photo-thumb, img[src*="/photos/files/"], .photo-item img, .photo-list img')
await expect(photos.first()).toBeVisible({ timeout: 8_000 })
expect(await photos.count()).toBeGreaterThanOrEqual(1)
})
test('Live-Data: Vance von den Kleinen Chaoten Detail rendert korrekt', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
// Gesundheits-Tab (leer OK, soll aber nicht crashen)
await page.getByRole('tab', { name: td.tabs.health }).click()
await expect(page).not.toHaveURL('/error')
// Gewicht-Tab
await page.getByRole('tab', { name: td.tabs.weight }).click()
await expect(page).not.toHaveURL('/error')
})
test('Live-Data: Stammbaum-Link von Tier-Detail navigiert zu StammbaumPage', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
// Stammbaum-Link suchen
const stammbaumLink = page.getByRole('link', { name: /Stammbaum/i }).first()
if (await stammbaumLink.isVisible()) {
await stammbaumLink.click()
await expect(page).toHaveURL(/stammbaum/)
} else {
// Direkt navigieren
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
}
// Stammbaum-SVG oder rd3t-Knoten sichtbar
await expect(page.locator('[data-testid="rd3t-svg"], svg.rd3t-svg, .stammbaum-page, svg').first()).toBeVisible({ timeout: 10_000 })
})
// ── 3. STAMMBAUM-SEITE ───────────────────────────────────────────────────────
test('Live-Data: Stammbaum rendert ohne Page-Overflow (390px)', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
// Irgendein SVG-Element muss sichtbar sein
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
const overflow = await page.evaluate(() => ({
bodyScrollWidth: document.body.scrollWidth,
viewportWidth: window.innerWidth,
}))
expect(overflow.bodyScrollWidth).toBeLessThanOrEqual(overflow.viewportWidth + 2)
})
test('Live-Data: Stammbaum hat Druck-Button', async ({ page }) => {
skipUnlessLive()
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
const printBtn = page.locator('button, a').filter({ hasText: /Drucken|Print|Ahnentafel/i })
await expect(printBtn.first()).toBeVisible()
})
// ── 4. WÜRFE-LISTE ───────────────────────────────────────────────────────────
test('Live-Data: Würfe-Liste zeigt mindestens 20 Einträge (865 Würfe importiert)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.litters)
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
const rows = page.locator('.litter-row, .gerbil-card, li a').filter({ hasText: /Wurf/i })
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThanOrEqual(20)
})
test('Live-Data: Wurf-Detail mit beiden Eltern rendert "Elterntiere"', async ({ page }) => {
skipUnlessLive()
// Direkt zu einem Wurf mit beiden Eltern navigieren (2026-02-10)
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
// Elterntiere-Section immer sichtbar
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// Vater-Link zu einem Tier
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
})
test('Live-Data: Wurf-Detail ohne Eltern rendert "Elterntiere" als "unbekannt"', async ({
page,
}) => {
skipUnlessLive()
// Wurf A (2010-02-18) hat weder Vater noch Mutter
await page.goto('/wuerfe/34852596-2bd7-48ad-9175-4223d3d99229')
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// "unbekannt" Platzhalter soll sichtbar sein (kein Link)
await expect(page.getByText(tl.detail.unknownParent ?? 'unbekannt')).toBeVisible()
})
// ── 5. BECKEN / KONTAKTE (leere Zustände) ────────────────────────────────────
test('Live-Data: Becken-Seite zeigt Leer-Zustand (keine Becken importiert)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.enclosures)
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Leer-Meldung oder "Neues Becken"-Button
const emptyOrNew = page.locator('*').filter({ hasText: de.pages.becken.empty }).or(
page.getByRole('link', { name: de.pages.becken.newButton })
)
await expect(emptyOrNew.first()).toBeVisible()
})
test('Live-Data: Kontakte-Seite zeigt Leer-Zustand (keine Kontakte importiert)', async ({
page,
}) => {
skipUnlessLive()
await gotoSection(page, de.nav.contacts)
await expect(page.getByRole('heading', { name: de.pages.kontakte.title, exact: true })).toBeVisible({ timeout: 8_000 })
await expect(page.getByText(de.pages.kontakte.empty)).toBeVisible()
})
// ── 6. GENETIK / PROBEVERPAARUNG ─────────────────────────────────────────────
test('Live-Data: Genetik-Seite lädt mit echten Genotypen', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.genetics)
await expect(page.getByRole('heading', { name: de.pages.genetik.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Probeverpaarung-UI sichtbar
await expect(page.getByText(de.pages.genetik.subtitle)).toBeVisible()
// Tier-Auswahl Vater/Mutter sichtbar
await expect(page.locator('select, input[type="search"], [placeholder]').first()).toBeVisible()
})
// ── 7. STATISTIK ─────────────────────────────────────────────────────────────
test('Live-Data: Statistik-Seite rendert Charts ohne Crash', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.statistics)
await expect(page.getByRole('heading', { name: de.pages.statistik.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Mindestens ein Chart-Canvas oder SVG (865 Würfe = Charts vorhanden)
const chart = page.locator('canvas, svg.bar-chart, svg.line-chart, .chart-wrapper, .bar-chart, .line-chart')
await expect(chart.first()).toBeVisible({ timeout: 12_000 })
})
// ── 8. EINSTELLUNGEN ─────────────────────────────────────────────────────────
test('Live-Data: Einstellungen-Seite lädt Züchterprofil', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.settings)
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title, exact: true })).toBeVisible({ timeout: 8_000 })
await expect(page.getByText(de.pages.einstellungen.zuchtprofil.title)).toBeVisible()
})
// ── 9. WEBSEITE (CMS) ────────────────────────────────────────────────────────
test('Live-Data: Webseite-Seite lädt ohne Crash', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.website)
await expect(page.getByRole('heading', { name: de.pages.webseite.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Seitenliste mit Seeds vorhanden (8 Seiten aus dem Seed)
await expect(page.locator('.gerbil-card, li').first()).toBeVisible({ timeout: 5_000 })
})
// ── 10. ANFRAGEN ─────────────────────────────────────────────────────────────
test('Live-Data: Anfragen-Seite zeigt Leer-Zustand (0 Anfragen)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.requests)
await expect(page.getByRole('heading', { name: de.pages.anfragen.title, exact: true })).toBeVisible({ timeout: 8_000 })
// Leer-Text soll sichtbar sein (keine Anfragen live)
await expect(page.getByText(de.pages.anfragen.empty)).toBeVisible()
})
// ── 11. KONSOLEN-FEHLER-MONITOR ───────────────────────────────────────────────
test('Live-Data: Keine unhandled-rejection-Fehler beim Durchlauf aller Hauptseiten', async ({
page,
}) => {
skipUnlessLive()
const errors: string[] = []
page.on('pageerror', (err) => errors.push(err.message))
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`)
})
// Navigiere direkt per URL statt gotoSection — gotoSection benutzt getByRole('navigation')
// welches bei Seiten mit Pager-Navigation (>1 Page) in strict-mode fehlschlägt (Infra-Bug).
for (const path of ['/', '/rennmaeuse', '/statistik', '/genetik', '/einstellungen']) {
await page.goto(path)
await page.waitForTimeout(800)
}
// Keine unkritischen Errors (ignoriere CORS-Warnungen, die im Dev-Modus normal sind)
const criticalErrors = errors.filter(
(e) =>
!e.includes('CORS') &&
!e.includes('favicon') &&
!e.includes('DevTools') &&
!e.includes('Warning:'),
)
expect(criticalErrors).toHaveLength(0)
})
// ── 12. DESKTOP-OVERFLOW-CHECK ───────────────────────────────────────────────
// (dieses Projekt-File läuft in beiden Viewports dank playwright.config.ts)
test('Live-Data: Rennmäuse-Liste kein horizontal Overflow (Desktop 1280px)', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row').first()).toBeVisible()
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 2)
})

View File

@@ -40,18 +40,13 @@ test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)',
page,
}) => {
skipUnlessLive()
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
await gotoSection(page, de.nav.litters)
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
// Direkt zu einem Wurf mit beiden Eltern navigieren (Re-Import #2.5, stabil).
// Wurf (aus Diagramm) 2026-02-10 — fatherId + motherId beide gesetzt.
// Kein nth()-Selektor mehr: bei 865 Würfen + Pager-Nav nicht deterministisch.
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
// Mindestens 1 Wurf in der Liste; ersten öffnen.
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
await expect(firstLink).toBeVisible()
await firstLink.click()
// Detailseite: Elterntier-Abschnitt ist sichtbar
await expect(page.getByText(tl.detail.parents)).toBeVisible()
// Detailseite: Elterntier-Abschnitt immer sichtbar (kein bedingtes Rendering)
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
})

View File

@@ -149,6 +149,8 @@ export function seedDb(): MockDb {
enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker',
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
]
const litters: Litter[] = [

View File

@@ -4,7 +4,7 @@ import { de, expect, gotoSection, test } from './fixtures'
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
@@ -30,5 +30,5 @@ test('unbekannte Route zeigt die deutsche 404-Seite', async ({ page }) => {
await page.goto('/diese-seite-gibt-es-nicht')
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
})

View File

@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
})
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
// Titel zeigt '(ohne Namen)'
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
// Karte selbst zeigt '(ohne Namen)' statt leer
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
})

View File

@@ -98,6 +98,48 @@ test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', as
await expect(page.getByText(/85\s*g/)).toBeVisible()
})
test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Krümel hat litterId 'w-kruemel' → Name 'Wurf K' → Link /wuerfe/w-kruemel
const litterLink = page.getByRole('link', { name: 'Wurf K' })
await expect(litterLink).toBeVisible()
await litterLink.click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
// Kein Link mit diesem Namen — es ist reiner Text
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeHidden()
})
test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock()
// Krümel hat originContactId='con-meier' → Link /kontakte/con-meier (Priorität vor originBreeder)
await page.goto('/rennmaeuse/kruemel')
const originLink = page.getByRole('link', { name: 'Zoohandlung Meier' })
await expect(originLink).toBeVisible()
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
})
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
// 'nameless-stub' hat name='' → Platzhalter '(ohne Namen)' soll als Link sichtbar sein
await expect(page.getByRole('link', { name: de.pages.gerbils.nameless })).toBeVisible()
})
test('Namenlose Tiere zeigen Platzhalter im Detail-Header (UI-POLISH-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub')
await expect(page.getByRole('heading', { name: de.pages.gerbils.nameless })).toBeVisible()
})
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/bearbeiten')
@@ -107,3 +149,26 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
})
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
await page.goto('/rennmaeuse/fridolin/bearbeiten')
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
})
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/bearbeiten')
// Standard: Unbekannt (null → value='')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
})

View File

@@ -1,4 +1,4 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'
import AppShell from './components/AppShell'
import BeckenPage from './pages/BeckenPage'
import BeckenDetailPage from './pages/BeckenDetailPage'
@@ -8,7 +8,6 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
import GerbilFormPage from './pages/GerbilFormPage'
import GenetikPage from './pages/GenetikPage'
import AbgabePage from './pages/AbgabePage'
import HomePage from './pages/HomePage'
import KontaktePage from './pages/KontaktePage'
import KontaktDetailPage from './pages/KontaktDetailPage'
import KontaktFormPage from './pages/KontaktFormPage'
@@ -28,12 +27,17 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage'
function BeckenRedirect() {
const { '*': splat } = useParams()
return <Navigate to={`/gehege${splat ? `/${splat}` : ''}`} replace />
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<HomePage />} />
<Route index element={<Navigate to="/rennmaeuse" replace />} />
<Route path="rennmaeuse">
<Route index element={<GerbilsPage />} />
<Route path="neu" element={<GerbilFormPage />} />
@@ -48,7 +52,9 @@ export default function App() {
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
</Route>
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
<Route path="becken">
{/* GEHEGE-RENAME: /becken → /gehege; redirect preserves bookmarks */}
<Route path="becken/*" element={<BeckenRedirect />} />
<Route path="gehege">
<Route index element={<BeckenPage />} />
<Route path="neu" element={<BeckenFormPage />} />
<Route path=":id" element={<BeckenDetailPage />} />

View File

@@ -54,6 +54,8 @@ export interface Gerbil {
* as resident (backend default true).
*/
isResident?: boolean
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
isDeaf?: boolean | null
}
/** Payload for POST /gerbils. */
@@ -76,6 +78,7 @@ export interface CreateGerbil {
characterTraits?: string[] | null
characterNote?: string | null
isResident?: boolean
isDeaf?: boolean | null
}
/** Payload for PUT /gerbils/{id} (all optional / partial update). */

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'
import { de } from '../strings/de'
const nameless = de.pages.gerbils.nameless
import { listGerbils } from '../api/gerbils'
import { andFilter, condition } from '../api/gridify'
import type { Gender, Gerbil, Paged } from '../api/types'
@@ -62,7 +64,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
setOpen(false)
}}
>
{g.name}
{g.name || nameless}
</button>
</li>
))}

View File

@@ -13,13 +13,12 @@ interface NavItem {
// Primary items are always visible in the phone bottom bar; secondary items
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
const PRIMARY: NavItem[] = [
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
]
const SECONDARY: NavItem[] = [
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
// INBOX-1: Anfragen-Posteingang

View File

@@ -44,7 +44,7 @@ export default function BeckenDetailPage() {
setDeleteError(null)
const result = await removal.run()
if (result.ok) {
navigate('/becken')
navigate('/gehege')
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
setDeleteError(t.delete.conflict)
@@ -58,7 +58,7 @@ export default function BeckenDetailPage() {
return (
<section className="page">
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
<Link to="/becken" className="btn">
<Link to="/gehege" className="btn">
{t.detail.back}
</Link>
</section>
@@ -81,7 +81,7 @@ export default function BeckenDetailPage() {
)}
</div>
<div className="head-actions">
<Link to={`/becken/${e.id}/bearbeiten`} className="btn btn--primary">
<Link to={`/gehege/${e.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</Link>
<button
@@ -92,7 +92,7 @@ export default function BeckenDetailPage() {
>
{t.delete.action}
</button>
<Link to="/becken" className="btn">
<Link to="/gehege" className="btn">
{t.detail.back}
</Link>
</div>

View File

@@ -45,7 +45,7 @@ export default function BeckenFormPage() {
}
setErrors({})
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
if (result.ok) navigate(`/becken/${result.value.id}`)
if (result.ok) navigate(`/gehege/${result.value.id}`)
// On failure mutation.error drives the inline alert; run() never throws.
}
@@ -81,7 +81,7 @@ export default function BeckenFormPage() {
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? t.form.saving : t.form.save}
</button>
<Link to={isEdit && id ? `/becken/${id}` : '/becken'} className="btn">
<Link to={isEdit && id ? `/gehege/${id}` : '/gehege'} className="btn">
{t.form.cancel}
</Link>
</div>

View File

@@ -35,7 +35,7 @@ export default function BeckenPage() {
</p>
)}
</div>
<Link to="/becken/neu" className="btn btn--primary">
<Link to="/gehege/neu" className="btn btn--primary">
+ {t.newButton}
</Link>
</header>
@@ -72,7 +72,7 @@ export default function BeckenPage() {
<ul className="card-list">
{items.map((e) => (
<li key={e.id}>
<Link to={`/becken/${e.id}`} className="gerbil-card">
<Link to={`/gehege/${e.id}`} className="gerbil-card">
<span className="gerbil-card__name">{e.name}</span>
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
</Link>

View File

@@ -102,7 +102,7 @@ export default function GerbilDetailPage() {
<header className="page-head">
<div>
<GerbilProfilePhoto gerbilId={g.id} />
<h2>{g.name}</h2>
<h2>{g.name || de.pages.gerbils.nameless}</h2>
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
{g.isResident === false && (
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
@@ -153,7 +153,7 @@ export default function GerbilDetailPage() {
{g.status === 'Deceased' && (
<>
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath} />
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
</>
)}
{g.status === 'GivenAway' && (
@@ -173,10 +173,24 @@ export default function GerbilDetailPage() {
}
/>
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
<Row label={t.fields.litter} value={lookup(litterName, g.litterId)} />
<Row label={t.fields.origin} value={lookup(contactName, g.originContactId)} />
<Row
label={t.fields.litter}
value={
g.litterId && litterName.has(g.litterId)
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
: lookup(litterName, g.litterId)
}
/>
<Row
label={t.fields.origin}
value={
g.originContactId && contactName.has(g.originContactId)
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
: (g.originBreeder || null)
}
/>
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
<Row label={t.fields.notes} value={g.notes} />
<Row label={t.fields.notes} value={g.notes || null} />
</dl>
<h3>{t.detail.genetics}</h3>

View File

@@ -21,10 +21,12 @@ interface FormState {
enclosureId: string
litterId: string
originContactId: string
originBreeder: string
receiverContactId: string
genotype: string
notes: string
isResident: boolean
isDeaf: boolean | null
}
const EMPTY: FormState = {
@@ -39,10 +41,12 @@ const EMPTY: FormState = {
enclosureId: '',
litterId: '',
originContactId: '',
originBreeder: '',
receiverContactId: '',
genotype: '',
notes: '',
isResident: true,
isDeaf: null,
}
function formFromGerbil(g: {
@@ -57,10 +61,12 @@ function formFromGerbil(g: {
enclosureId: string | null
litterId: string | null
originContactId: string | null
originBreeder?: string | null
receiverContactId: string | null
genotype: string | null
notes: string | null
isResident?: boolean | null
isDeaf?: boolean | null
}): FormState {
return {
name: g.name,
@@ -74,10 +80,12 @@ function formFromGerbil(g: {
enclosureId: g.enclosureId ?? '',
litterId: g.litterId ?? '',
originContactId: g.originContactId ?? '',
originBreeder: g.originBreeder ?? '',
receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '',
notes: g.notes ?? '',
isResident: g.isResident ?? true,
isDeaf: g.isDeaf ?? null,
}
}
@@ -167,10 +175,12 @@ export default function GerbilFormPage() {
enclosureId: nn(form.enclosureId),
litterId: nn(form.litterId),
originContactId: nn(form.originContactId),
originBreeder: nn(form.originBreeder),
receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype),
notes: nn(form.notes),
isResident: form.isResident,
isDeaf: form.isDeaf,
}
const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -326,6 +336,16 @@ export default function GerbilFormPage() {
</select>
</label>
<label className="field">
<span>{t.fields.originBreeder}</span>
<input
className="input"
value={form.originBreeder}
placeholder="z. B. Zucht der Kleinen Chaoten"
onChange={(e) => set('originBreeder', e.target.value)}
/>
</label>
<label className="field">
<span>{t.fields.receiver}</span>
<select
@@ -360,6 +380,21 @@ export default function GerbilFormPage() {
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label>
<label className="field">
<span>{t.fields.isDeaf}</span>
<select
value={form.isDeaf === null ? '' : String(form.isDeaf)}
onChange={(e) => {
const v = e.target.value
set('isDeaf', v === '' ? null : v === 'true')
}}
>
<option value="">{t.form.isDeafUnknown}</option>
<option value="true">{t.form.isDeafYes}</option>
<option value="false">{t.form.isDeafNo}</option>
</select>
</label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>
<input

View File

@@ -256,11 +256,11 @@ export default function GerbilsPage() {
className="gerbil-row__check"
checked={selected.has(g.id)}
onChange={() => toggleSelect(g.id)}
aria-label={g.name}
aria-label={g.name || t.nameless}
/>
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
<span className="gerbil-card__name">
{g.name}
{g.name || t.nameless}
{g.isResident === false && (
<span className="badge badge--external" title={t.externalTitle}>
{t.externalBadge}

View File

@@ -38,12 +38,12 @@ const sections: Section[] = [
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
<ol>
<li>Becken anlegen (wo leben deine Tiere?)</li>
<li>Gehege anlegen (wo leben deine Tiere?)</li>
<li>Rennmäuse anlegen mit Foto</li>
<li>Würfe eintragen</li>
<li>Genetik erkunden</li>
</ol>
<p className="hilfe-hint">Tipp: Lege zuerst die Becken an — dann kannst du beim Erfassen der Tiere direkt das richtige Becken auswählen.</p>
<p className="hilfe-hint">Tipp: Lege zuerst die Gehege an — dann kannst du beim Erfassen der Tiere direkt das richtige Gehege auswählen.</p>
</>
),
},
@@ -53,7 +53,7 @@ const sections: Section[] = [
content: (
<>
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>{g.newButton}"</strong> legst du ein neues Tier an.</p>
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Becken und Herkunft eintragen.</p>
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Gehege und Herkunft eintragen.</p>
<p>Auf der Detailseite findest du drei Tabs:</p>
<ul>
<li><strong>„{g.detail.tabs.photos}"</strong> Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
@@ -69,9 +69,9 @@ const sections: Section[] = [
title: t.sections.becken,
content: (
<>
<p>Unter <strong>{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Becken an (nur Name ist Pflicht).</p>
<p>Unter <strong>{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Gehege an (nur Name ist Pflicht).</p>
<p>Die Detailseite zeigt dir die aktuellen <strong>{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
<p>Ein Becken lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
<p>Ein Gehege lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
</>
),

View File

@@ -1,10 +0,0 @@
import { de } from '../strings/de'
export default function HomePage() {
return (
<section>
<h2>{de.pages.home.title}</h2>
<p>{de.pages.home.intro}</p>
</section>
)
}

View File

@@ -276,7 +276,7 @@ export default function StammbaumPage() {
<>
<section className="page stammbaum-screen">
<header className="page-head">
<h2>{t.titleFor(root.gerbil.name)}</h2>
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
<div className="head-actions">
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
{t.backToAnimal}
@@ -377,7 +377,7 @@ function PedigreeCard({
<div className="pedigree-card__body">
<div className="pedigree-card__name">
<SexIcon gender={g.gender} />
<span className="pedigree-card__nametext">{g.name}</span>
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
</div>
{farbschlag && (
<span
@@ -439,7 +439,7 @@ function PrintPedigree({
return (
<div className="stammbaum-print">
<header className="stammbaum-print__head">
<h1>{t.titleFor(root.gerbil.name)}</h1>
<h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
<p className="stammbaum-print__meta">
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
</p>
@@ -494,7 +494,7 @@ function PrintCell({
<div className={base} style={style}>
<div className="stammbaum-print__name">
{sexSymbol}
{g.name}
{g.name || de.pages.gerbils.nameless}
</div>
{g.dateOfBirth && (
<div className="stammbaum-print__sub">

View File

@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
checked={selectedIds.has(g.id)}
onChange={() => toggleAnimal(g.id)}
/>
<span className="wizard-pick__name">{g.name}</span>
<span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
<span className="wizard-pick__meta">
{[
genderLabel(g.gender),
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
</div>
<div className="def-row">
<dt>{de.pages.vertraege.fields.animals}</dt>
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
<dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
</div>
<div className="def-row">
<dt>{de.pages.vertraege.fields.price}</dt>

View File

@@ -71,9 +71,9 @@ export default function WurfFormPage() {
name: l.name,
date: l.date ?? '',
fatherId: l.fatherId ?? '',
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
motherId: l.motherId ?? '',
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
notes: l.notes ?? '',
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
) : (
<AnimalPicker
gender="male"
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
/>
)}
</fieldset>
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
) : (
<AnimalPicker
gender="female"
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
/>
)}
</fieldset>

View File

@@ -9,13 +9,12 @@ export const de = {
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
},
nav: {
home: 'Start',
gerbils: 'Rennmäuse',
litters: 'Würfe',
breeders: 'Züchter',
genetics: 'Genetik',
// FEAT-2 (Oscar): Becken + Kontakte
enclosures: 'Becken',
// FEAT-2 (Oscar): Becken + Kontakte (GEHEGE-RENAME: Becken → Gehege)
enclosures: 'Gehege',
contacts: 'Kontakte',
// FEAT-7 (Kelly): Statistik
statistics: 'Statistik',
@@ -37,13 +36,9 @@ export const de = {
website: 'Webseite',
},
pages: {
home: {
title: 'Willkommen',
intro:
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
},
gerbils: {
title: 'Rennmäuse',
nameless: '(ohne Namen)',
// Liste
newButton: 'Neue Rennmaus',
empty: 'Keine Rennmäuse gefunden.',
@@ -82,9 +77,11 @@ export const de = {
causeOfDeath: 'Todesursache',
goHomeDate: 'Abgabedatum',
litter: 'Wurf',
enclosure: 'Becken',
enclosure: 'Gehege',
colorVariety: 'Farbschlag',
origin: 'Herkunft',
originBreeder: 'Herkunft (Freitext)',
isDeaf: 'Gehörlos',
receiver: 'Abnehmer',
genotype: 'Genotyp',
notes: 'Notizen',
@@ -119,6 +116,9 @@ export const de = {
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja',
isDeafNo: 'Nein',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Speichern …',
@@ -253,8 +253,8 @@ export const de = {
markMultiAction: 'Ausgewählte zur Abgabe stellen',
grouping: {
title: 'Gruppen',
byEnclosure: 'Nach Becken vorgeschlagen',
ungrouped: 'Ohne Becken',
byEnclosure: 'Nach Gehege vorgeschlagen',
ungrouped: 'Ohne Gehege',
moveTo: 'Verschieben nach',
newGroup: 'Neue Gruppe',
groupLabel: 'Gruppe',
@@ -298,12 +298,12 @@ export const de = {
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
},
},
// ── FEAT-2 (Oscar): Becken (Enclosures) ──
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
becken: {
title: 'Becken',
newButton: 'Neues Becken',
empty: 'Keine Becken gefunden.',
countLabel: 'Becken',
title: 'Gehege',
newButton: 'Neues Gehege',
empty: 'Keine Gehege gefunden.',
countLabel: 'Gehege',
searchPlaceholder: 'Name suchen …',
fields: {
name: 'Name',
@@ -311,18 +311,18 @@ export const de = {
},
occupancy: {
title: 'Bewohner',
empty: 'Dieses Becken ist derzeit leer.',
empty: 'Dieses Gehege ist derzeit leer.',
countOne: 'Tier',
countMany: 'Tiere',
},
detail: {
edit: 'Bearbeiten',
back: 'Zurück zur Liste',
notFound: 'Dieses Becken wurde nicht gefunden.',
notFound: 'Dieses Gehege wurde nicht gefunden.',
},
form: {
createTitle: 'Neues Becken anlegen',
editTitle: 'Becken bearbeiten',
createTitle: 'Neues Gehege anlegen',
editTitle: 'Gehege bearbeiten',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Speichern …',
@@ -332,9 +332,9 @@ export const de = {
},
delete: {
action: 'Löschen',
confirmMessage: 'Soll dieses Becken wirklich gelöscht werden?',
confirmMessage: 'Soll dieses Gehege wirklich gelöscht werden?',
conflict:
'Becken ist nicht leer — bitte zuerst alle Tiere in ein anderes Becken umsetzen.',
'Gehege ist nicht leer — bitte zuerst alle Tiere in ein anderes Gehege umsetzen.',
},
},
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
@@ -731,7 +731,7 @@ export const de = {
sections: {
ersteSchritte: 'Erste Schritte',
tiere: 'Rennmäuse anlegen & Fotos',
becken: 'Becken',
becken: 'Gehege',
wuerfe: 'Würfe & Jungtiere',
genetik: 'Probeverpaarung & Genetik',
stammbaum: 'Stammbaum',

View File

@@ -129,7 +129,9 @@ def norm_zucht(z):
if not z:
return ""
n = z.lower()
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
# TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
# (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b.
n = re.sub(r"\bv\.\s?d\.", " ", n)
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
@@ -137,6 +139,15 @@ def norm_zucht(z):
return ZUCHT_ALIASES.get(key, key)
def is_clan_zucht(z):
"""True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery.
Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten',
'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc.
"""
return norm_zucht(z) == "kleinechaote"
def canon_pair(raw):
"""Full raw name -> (normalised call-name, canonical zucht)."""
name, zucht = split_name_zucht(raw)

View File

@@ -280,6 +280,33 @@ 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 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
# 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
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
e.is_clan_zucht("kleinen Chaoten"))
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
e.is_clan_zucht("v.d. Kleinen Chaoten"))
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
e.is_clan_zucht("ZdkC"))
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
e.is_clan_zucht("von den Kleinen Chaoten"))
check("KC-matcher: empty string -> NOT clan",
not e.is_clan_zucht(""))
check("KC-matcher: 'Black Forest' -> NOT clan",
not e.is_clan_zucht("Black Forest"))
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)