Compare commits

..

26 Commits

Author SHA1 Message Date
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
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
d116b8d8f1 UX-MOBILE-2: Genotyp-Tabellen-Overflow behoben (scrollbar statt page-overflow)
- BreedingResultView: <table class="genotype-table"> in <div class="genotype-table-scroll"> gewrapped.
- index.css: .genotype-table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch }.
  .genotype-table td { white-space: nowrap } → Tabelle scrollt statt Zellen umzubrechen.
  width: 100% entfernt (Tabelle auto-sized to content).
- index.css: .app-main { overflow-x: hidden } → verhindert, dass breite Inhalte
  den Page-Body horizontal überlaufen lassen (document.scrollWidth == clientWidth).
- e2e/genotype-table.spec.ts (4 Tests): Wrapper sichtbar, Wrapper selbst scrollbar
  (scrollWidth > clientWidth), keine Page-Overflow auf 390px. Beide Viewports.
- Vitest 78 / e2e 106 grün.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:41:59 +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
a2f5e848d1 Merge feature/qa-2: Verbatim-few-shots (POLISH-2), CI-Flake-Fix, skipUnlessLive+Live-Smoke, Abgabe-Wizard-e2e [god-QA validated]
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
2026-06-06 15:34:38 +02:00
68447896ee QA-2: Fix smart-quote raw-string delimiters in SaleAdPromptBuilder (POLISH-2 BOUNCE) 2026-06-06 15:30:46 +02:00
c357a18418 QA-2: Fix Abgabe-e2e Selektoren + ForSale in enc-leer (no Becken-Konflikt) + charakter scoped 2026-06-06 15:22:34 +02:00
791e9f91cc QA-2: Abgabe-Wizard e2e (ForSale-Seed, sale-ad+contracts Mock, abgabe.spec.ts) 2026-06-06 15:16:38 +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
868c5f5cd7 QA-2: skipUnlessLive() + Live-Smoke (Liste>=10, Suche Treffer, Eltern-Links) 2026-06-06 15:11:22 +02:00
ddfa3614a4 QA-2: CI-Flake fix — explizite Heading-Waits nach gotoSection in 'Kontakt anlegen' 2026-06-06 15:09:15 +02:00
13da8f2502 QA-2: POLISH-2 — verbatim few-shots von kleine-chaoten.jimdofree.com (Gruppe 1+4) 2026-06-06 15:08:56 +02:00
38 changed files with 4125 additions and 211 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

@@ -67,9 +67,9 @@ namespace GerbilManager.Tests
Assert.Contains("Zucht der kleinen Chaoten", prompt);
Assert.Contains("NIEMALS Preise", prompt);
Assert.Contains("KEINE Fakten erfinden", prompt);
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
Assert.Contains("Status: FREI", prompt);
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
}

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,

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

@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.SaleAd
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
public static class SaleAdPromptBuilder
{
/// <summary>
/// Two few-shot examples derived from the documented listing template.
/// NOTE: the research file documents the format and one real tagline;
/// these examples are synthesized to that template. Swap in verbatim
/// listings from kleine-chaoten.jimdofree.com when available.
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
/// These are real listings in the breeder's own voice — the strongest grounding source.
/// </summary>
private const string ExampleListing1 = """
Status: FREI
Status: LOCKER RESERVIERT LEONIE
Großer und kleiner Bruder Dynamik Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
Großer und kleiner Bruder Dynamik Männliches Rennmaus Duo sucht ein liebevolles Zuhause
Balu CP-Agouti, geboren am 12.03.2024
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
Hier steht ein außergewöhnlich harmonisches Rennmaus-Duo zur Abgabe frei, das nicht nur durch seinen Charakter, sondern auch durch seine besondere Geschichte überzeugt. Die zwei verhalten sich als ob sie Brüder wären und sind unzertrennlich. Das Duo kann ab sofort ausziehen.
Benny Schwarz Schecke, geboren am 12.03.2024
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest getrennt werden die beiden deshalb nicht.
Der Ältere von beiden trägt die Farbe CP-Agouti und ist am 11.11.2025 geboren. Er ist ein neugieriger, aktiver und sozialer kleiner Kerl. Er liebt es, seine Umgebung zu erkunden, ist gerne in Bewegung und bringt dabei eine angenehme Ausgeglichenheit mit. Er wirkt sicher, aufmerksam und zeigt ein rundum unkompliziertes Wesen.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
Sein, etwas jünger, Partner trägt die Farbe Schwarz Schecke und ist am 26.02.2026 geboren. Statt des in diesem Alter oft typischen Kräftemessens zeigt er ein erstaunlich sanftes, ruhiges und sehr soziales Verhalten. Man merkt ihm an, wie feinfühlig und verträglich er ist.
""";
private const string ExampleListing2 = """
Status: LOCKER RESERVIERT Anna
Status: FREI
Zwei Schwestern, ein Herz und ganz viel Neugier Weibliches Duo sucht seine Menschen
Unzertrennliches Mutter-Tochter Duo sucht gemeinsam neue Körnergeber
Frieda Gold, geboren am 28.06.2024
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
Die Mama ist am 13.08.2025 geboren und trägt die Farbe Kohlfuchs. Sie ist eine eher ruhige und vorsichtige Rennmaus, die neuen Situationen zunächst aufmerksam begegnet. Gibt man ihr etwas Zeit, zeigt sich schnell ihre neugierige Seite und sie kommt gerne schauen, was im Gehege passiert. Sie ist ein echtes Energiebündel, liebt es zu buddeln, zu rennen und alles an Beschäftigungsmaterial besonders Klorollen kreativ umzugestalten. Charakterlich zeigt sie sich sehr sozial und friedlich; besonders hervorzuheben ist ihre fürsorgliche Art als Mutter.
Fine Agouti, geboren am 28.06.2024
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
Die Tochter ist am 26.02.2026 geboren und trägt die Farbe Agouti-Schecke. Sie ist ihrer Mama sehr ähnlich, dabei aber schon ein kleines bisschen mutiger. Auch sie beobachtet erst gerne in Ruhe, bevor die Neugier siegt. Sie ist sehr anhänglich und orientiert sich stark an ihrer Mutter.
Die Schwestern ziehen selbstverständlich nur zusammen um.
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
""";
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>

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

@@ -0,0 +1,129 @@
/**
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
*
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const ta = de.pages.abgabe
const tw = de.pages.vertraege.wizard
test.describe('Abgabe', () => {
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
await expect(page.getByText(ta.grouping.title)).toBeVisible()
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
// Inserat-Vorschau (listing-preview) wird gerendert
await expect(page.locator('.listing-preview').first()).toBeVisible()
// Status-Dropdown standard: FREI
await expect(
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
).toHaveValue('free')
})
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
})
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
skipUnlessMock()
mockDb!.saleAdConfigured = false
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
await page.getByRole('button', { name: ta.ai.improve }).first().click()
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
})
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
page,
}) => {
skipUnlessMock()
await gotoSection(page, de.nav.forSale)
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
await page.getByRole('button', { name: ta.export.finish }).first().click()
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer auswählen
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
})
})
test.describe('Vertrag-Wizard', () => {
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
skipUnlessMock()
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
).toBeChecked()
await expect(
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
).toBeChecked()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: Preis & Datum
await page.getByLabel(tw.priceLabel).fill('50,00')
await page.getByRole('button', { name: tw.next }).click()
// Schritt 4: Zusammenfassung + Erzeugen
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
await expect(page.getByText('Balu Abgabe')).toBeVisible()
await page.getByRole('button', { name: tw.generate }).click()
// Erfolg: Vertrag erstellt, Download-Link vorhanden
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
})
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
await page.getByRole('button', { name: tw.next }).click()
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
})
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/vertraege/neu?tiere=sale-balu')
// Schritt 1: Abnehmer wählen
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 2: Tiere bestätigen
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
// Schritt 3: leeres Preisfeld -> Fehler
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
await page.getByRole('button', { name: tw.next }).click()
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
})
})

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()
@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
await gotoSection(page, de.nav.contacts)
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
// wartet aber nicht auf den vollständigen Seitenaufbau).
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
await page.getByRole('link', { name: tk.newButton }).click()
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
const name = uniqueName('Kontakt')
await page.getByLabel(`${tk.fields.name} *`).fill(name)
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten

View File

@@ -17,5 +17,10 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
await page.goto('/abgabe')
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
const kruemelSection = page.locator('.group-composer__animal').filter({
has: page.locator('strong', { hasText: 'Krümel' }),
})
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
})

View File

@@ -39,12 +39,23 @@ export function skipUnlessMock() {
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
}
/**
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
* wenn wir nur testen, ob die Seite lädt.
*/
export function skipUnlessLive() {
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
}
/**
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
* 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,54 @@
/**
* UX-MOBILE-2: Genotyp-Tabellen-Overflow — die Tabelle scrollt horizontal,
* die Seite selbst bleibt NICHT breiter als der Viewport (kein horizontaler
* Page-Overflow auf 390px).
*/
import { de, expect, skipUnlessMock, test } from './fixtures'
const tl = de.pages.litters
test('Phone: Genotyp-Detail-Tabelle scrollt im eigenen Wrapper, kein Page-Overflow', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
// Wurf w-kruemel hat beide Eltern mit Genotypen → BreedingResultView rendert.
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
// Genotyp-Detail-Tabelle ausklappen.
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
// Scroll-Wrapper und Tabelle müssen vorhanden und sichtbar sein.
const wrapper = page.locator('.genotype-table-scroll').first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('.genotype-table')).toBeVisible()
// Wrapper ist selbst scrollbar (Tabelle breiter als der sichtbare Bereich).
const wrapperScrollable = await wrapper.evaluate(
(el) => el.scrollWidth > el.clientWidth,
)
expect(wrapperScrollable).toBe(true)
// Die Seite selbst darf NICHT breiter als der Viewport sein.
const pageOverflow = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
)
expect(pageOverflow).toBe(false)
})
test('Desktop: Genotyp-Detail-Tabelle zeigt Wrapper auch auf breitem Viewport', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
await expect(page.locator('.genotype-table-scroll').first()).toBeVisible()
await expect(page.locator('.genotype-table').first()).toBeVisible()
})

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

@@ -0,0 +1,52 @@
/**
* QA-2: Live-Modus-Smoke — läuft NUR gegen das echte Backend (E2E_BASE_URL).
*
* Lektion (aus dem Such-Bug): Mock-Tests spiegeln nur den Frontend-Code.
* Ein Backend-Bug (falscher Filter, leere Antwort, fehlende Migration) bleibt
* unsichtbar, wenn Live-Tests nur "Seite lädt" prüfen. Diese Tests assertieren
* ERGEBNISSE: Liste nicht leer, Suche liefert Treffer, Detail zeigt Stammdaten
* und Eltern-Links.
*
* Voraussetzung für den Eltern-Link-Test: Re-Import #2 durchgeführt
* (322 Tiere, 882 Würfe live; mindestens ein Wurf hat beide Eltern).
*/
import { de, expect, gotoSection, skipUnlessLive, test } from './fixtures'
const t = de.pages.gerbils
const tl = de.pages.litters
test('Live: Tiere-Liste enthält nach Re-Import mindestens 10 Einträge', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Leere Liste = Backend-Bug (falscher Filter, fehlende DB-Migration o. ä.)
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible()
expect(await rows.count()).toBeGreaterThanOrEqual(10)
})
test('Live: Namenssuche gibt Treffer zurück — nicht nur HTTP 200', async ({ page }) => {
skipUnlessLive()
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kurzer Prefix: bei 322 Tieren mindestens 1 Treffer. Kein Treffer = Gridify-Bug.
await page.getByPlaceholder(t.searchPlaceholder).first().fill('a')
const rows = page.locator('.gerbil-row')
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
expect(await rows.count()).toBeGreaterThanOrEqual(1)
})
test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)', async ({
page,
}) => {
skipUnlessLive()
// 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')
// 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

@@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
contracts: collection(db.contracts as unknown as Row[], 'contract'),
}
const handler = async (route: Route) => {
@@ -205,6 +206,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route
if (path === '/gerbils/sale-ad' && method === 'POST') {
if (!db.saleAdConfigured) {
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
}
return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" Generierter Inserat-Text für den Mock.' })
}
// ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient)
const cm = path.match(/^\/contracts\/([^/]+)\/file$/)
if (cm && method === 'GET') {
return route.fulfill({
status: 200,
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` },
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
})
}
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
// generischen /gerbils/:id-Route abfangen.
if (path === '/gerbils/breeders' && method === 'GET') {
@@ -313,6 +333,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
}
}
// ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben
if (path === '/contracts' && method === 'POST') {
const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null }
const id = newId('contract')
const contract = {
id,
contactId: body.contactId,
gerbilIds: body.gerbilIds ?? [],
price: body.price,
handoverDate: body.handoverDate,
contractDate: body.contractDate ?? body.handoverDate,
fileName: `vertrag-${id}.docx`,
createdAt: new Date().toISOString(),
url: `/contracts/${id}/file`,
}
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
// Tiere auf GivenAway setzen (wie das echte Backend)
for (const gid of body.gerbilIds ?? []) {
const g = db.gerbils.find((x) => x.id === gid)
if (g) {
g.status = 'GivenAway'
g.receiverContactId = body.contactId
g.goHomeDate = body.handoverDate
}
}
return json(route, 201, contract)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -44,6 +44,19 @@ export interface MockPage {
blocks: MockBlock[]
}
/** ABGABE: Abgabevertrag (mirrors SaleContract in src/api/contracts.ts). */
export interface MockContract {
id: string
contactId: string
price: number
handoverDate: string
contractDate: string
fileName: string
createdAt: string
gerbilIds: string[]
url: string
}
export interface MockDb {
gerbils: Gerbil[]
litters: Litter[]
@@ -58,6 +71,9 @@ export interface MockDb {
requests: InboxRequest[]
aiConfigured: boolean
mailConfigured: boolean
// ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[]
saleAdConfigured: boolean
}
function gerbil(
@@ -119,6 +135,22 @@ export function seedDb(): MockDb {
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
{
...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'),
status: 'ForSale' as const,
enclosureId: 'enc-leer',
notes: 'ruhig, nimmt Leckerlis aus der Hand',
},
{
...gerbil('sale-benny', 'Benny Abgabe', 'male', '2024-03-12', null, 'cv-schwarz-schecke'),
status: 'ForSale' as const,
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[] = [
@@ -258,5 +290,7 @@ export function seedDb(): MockDb {
requests,
aiConfigured: true,
mailConfigured: true,
contracts: [],
saleAdConfigured: true,
}
}

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

@@ -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')

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

@@ -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

@@ -65,28 +65,30 @@ export default function BreedingResultView({ result, title }: BreedingResultView
</button>
{showGenotypes && (
<table className="genotype-table">
<thead>
<tr>
<th>{t.genotypeLabel}</th>
<th>{t.genotypePreview}</th>
<th>{t.probability}</th>
</tr>
</thead>
<tbody>
{result.offspring.map((o) => (
<tr key={o.genotype}>
<td>
<code>{o.genotype}</code>
</td>
<td>{o.farbschlag}</td>
<td>
{o.probability.percent} <small>({o.probability.text})</small>
</td>
<div className="genotype-table-scroll">
<table className="genotype-table">
<thead>
<tr>
<th>{t.genotypeLabel}</th>
<th>{t.genotypePreview}</th>
<th>{t.probability}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{result.offspring.map((o) => (
<tr key={o.genotype}>
<td>
<code>{o.genotype}</code>
</td>
<td>{o.farbschlag}</td>
<td>
{o.probability.percent} <small>({o.probability.text})</small>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}

View File

@@ -77,6 +77,10 @@ a {
padding: 1rem;
max-width: 60rem;
width: 100%;
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
the page body scroll horizontally. The .genotype-table-scroll wrapper
provides the per-table horizontal scroll. */
overflow-x: hidden;
}
/* Tab-Leiste unten */
@@ -559,8 +563,14 @@ textarea {
white-space: nowrap;
}
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
instead of overflowing the page. */
.genotype-table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.genotype-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
font-size: 0.9rem;
@@ -571,6 +581,7 @@ textarea {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.genotype-table code {

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

@@ -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

@@ -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,7 +77,7 @@ export const de = {
causeOfDeath: 'Todesursache',
goHomeDate: 'Abgabedatum',
litter: 'Wurf',
enclosure: 'Becken',
enclosure: 'Gehege',
colorVariety: 'Farbschlag',
origin: 'Herkunft',
receiver: 'Abnehmer',
@@ -253,8 +248,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 +293,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 +306,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 +327,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 +726,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',