Compare commits
66 Commits
feature/ux
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| 55c298ad1f | |||
| e1e088f041 | |||
| 358e30db1f | |||
| 0b9b03eb2d | |||
| d4c9be23c8 | |||
| f6bfc0ebab | |||
| 3160b66697 | |||
| 39767cef0f | |||
| 162a0fafb1 | |||
| c26ac5c6dd | |||
| 2bbdfe2b71 | |||
| 2c3f342905 | |||
| b165916dfb | |||
| 797b38df34 | |||
| c34d63e0fd | |||
| a2bdf9626c | |||
| 0d4b560df8 | |||
| 8dfad32918 | |||
| 1ee176a24a | |||
| 97acaf6b10 |
@@ -48,6 +48,26 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||||
|
|
||||||
|
- name: dotnet-ef Tool installieren
|
||||||
|
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||||
|
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||||
|
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||||
|
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||||
|
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||||
|
run: |
|
||||||
|
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||||
|
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: DB-3 EF Migrations Drift-Check
|
||||||
|
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||||
|
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||||
|
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||||
|
run: >
|
||||||
|
dotnet ef migrations has-pending-model-changes
|
||||||
|
--project GerbilManagerWebAPI
|
||||||
|
--startup-project GerbilManagerWebAPI
|
||||||
|
--no-build --configuration Release
|
||||||
|
|
||||||
- name: Tests ausfuehren
|
- name: Tests ausfuehren
|
||||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||||
|
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -131,6 +131,9 @@ $RECYCLE.BIN/
|
|||||||
# MemPalace per-project files (issue #185)
|
# MemPalace per-project files (issue #185)
|
||||||
mempalace.yaml
|
mempalace.yaml
|
||||||
entities.json
|
entities.json
|
||||||
|
|
||||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||||
GerbilManagerWebAPI/photo-storage/
|
GerbilManagerWebAPI/photo-storage/
|
||||||
|
|
||||||
|
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
|
||||||
|
GerbilManagerWebAPI/.data-protection-keys/
|
||||||
|
|||||||
@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
[Fact]
|
[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 doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
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);
|
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||||
|
|
||||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
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
|
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||||
|
|||||||
@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(2, await db.Litters.CountAsync());
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||||
|
{
|
||||||
|
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||||
|
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||||
|
using var db = NewDb();
|
||||||
|
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
// ExternalRef is set on created litters
|
||||||
|
var litters = await db.Litters.ToListAsync();
|
||||||
|
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||||
|
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||||
|
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||||
|
|
||||||
|
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||||
|
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||||
|
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||||
|
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Litters.Created);
|
||||||
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||||
{
|
{
|
||||||
@@ -545,6 +570,32 @@ namespace GerbilManager.Tests
|
|||||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
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]
|
[Theory]
|
||||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||||
[InlineData("5.3.21", 2021, 3, 5)]
|
[InlineData("5.3.21", 2021, 3, 5)]
|
||||||
@@ -554,6 +605,74 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(new DateOnly(y, m, d), date);
|
Assert.Equal(new DateOnly(y, m, d), date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
|
||||||
|
{
|
||||||
|
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
|
||||||
|
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
|
||||||
|
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
|
||||||
|
// stable ExternalRef without throwing.
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||||
|
"conflict":false}]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
// Run 1: load the animal normally
|
||||||
|
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||||
|
|
||||||
|
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
|
||||||
|
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
|
||||||
|
g.Name = "Umbenannt Tier";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
|
||||||
|
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||||
|
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
|
||||||
|
{
|
||||||
|
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
|
||||||
|
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
|
||||||
|
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
// Exact Agouti genotype, no explicit Farbschlag name
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
|
||||||
|
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
|
||||||
|
"conflict":false}]
|
||||||
|
""");
|
||||||
|
using var db = NewDb();
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
|
||||||
|
// ColorVarietyId must be set even though no explicit Farbschlag name was given
|
||||||
|
Assert.NotNull(tier.ColorVarietyId);
|
||||||
|
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
|
||||||
|
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
|
||||||
|
Assert.Equal("Agouti", variety!.Name);
|
||||||
|
// Report counter should reflect the genotype derivation
|
||||||
|
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
// ---- fixtures ----
|
// ---- fixtures ----
|
||||||
private const string LittersJson = """
|
private const string LittersJson = """
|
||||||
[
|
[
|
||||||
|
|||||||
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Names;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||||
|
/// fence strip), 503-not-configured path, upstream-error path.
|
||||||
|
/// </summary>
|
||||||
|
public class NameSuggestionTests
|
||||||
|
{
|
||||||
|
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||||
|
Assert.Contains("reinen JSON-Array", prompt);
|
||||||
|
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||||
|
Assert.Contains("name", prompt);
|
||||||
|
Assert.Contains("meaning", prompt);
|
||||||
|
Assert.Contains("origin", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||||
|
Assert.Contains("6", prompt);
|
||||||
|
Assert.Contains("\"A\"", prompt);
|
||||||
|
Assert.Contains("weibliche", prompt);
|
||||||
|
Assert.Contains("norn,mythg", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||||
|
Assert.Contains("5", prompt);
|
||||||
|
Assert.DoesNotContain("Buchstaben", prompt);
|
||||||
|
Assert.DoesNotContain("weibliche", prompt);
|
||||||
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||||
|
Assert.DoesNotContain("weibliche", prompt);
|
||||||
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||||
|
{
|
||||||
|
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(json);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Astrid", result[0].Name);
|
||||||
|
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||||
|
Assert.Equal("Altnordisch", result[0].Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Aiko", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Luna", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||||
|
{
|
||||||
|
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Sol", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||||
|
{
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, "key", "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", null, "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", "key", null)]
|
||||||
|
[InlineData(null, null, null)]
|
||||||
|
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||||
|
string? baseUrl, string? apiKey, string? model)
|
||||||
|
{
|
||||||
|
var service = CreateService(baseUrl, apiKey, model,
|
||||||
|
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 5);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||||
|
Assert.Null(result.Suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||||
|
{
|
||||||
|
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||||
|
var handler = new StubHandler(_ => Canned(payload));
|
||||||
|
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.NotNull(result.Suggestions);
|
||||||
|
Assert.Equal(2, result.Suggestions!.Count);
|
||||||
|
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||||
|
var handler = new StubHandler(_ => Canned(fenced));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 1);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 3);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||||
|
Assert.Null(result.Suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 3);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helfer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NameSuggestionService CreateService(
|
||||||
|
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||||
|
{
|
||||||
|
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||||
|
return new NameSuggestionService(new HttpClient(handler), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Canned(string content)
|
||||||
|
{
|
||||||
|
var completion = new
|
||||||
|
{
|
||||||
|
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||||
|
};
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(respond(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
|
||||||
|
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
|
||||||
|
/// send partial bodies — all other fields must be preserved unchanged.</summary>
|
||||||
|
public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
private static Guid ExtractId(string json) =>
|
||||||
|
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
|
||||||
|
|
||||||
|
private static string? GetStr(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString() : null;
|
||||||
|
|
||||||
|
private static string? GetNested(JsonElement el, string prop) =>
|
||||||
|
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
|
||||||
|
|
||||||
|
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||||
|
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||||
|
{
|
||||||
|
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
|
||||||
|
// when multiple tests in the same fixture share the SQLite connection.
|
||||||
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
gender = "female",
|
||||||
|
genotype = "aa CC DD ee GG PP spsp rere",
|
||||||
|
originBreeder = "Zucht der Kleinen Chaoten",
|
||||||
|
isDeaf = false,
|
||||||
|
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||||
|
characterNote = "Liebling der Familie",
|
||||||
|
notes = "Eine Notiz",
|
||||||
|
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForSale_toggle_does_not_wipe_other_fields()
|
||||||
|
{
|
||||||
|
// Arrange: create a fully-populated gerbil
|
||||||
|
var id = await CreateFullGerbil("ForSale-Tier");
|
||||||
|
|
||||||
|
// Act: send only { status: 'ForSale' } — exactly what the detail page does
|
||||||
|
var put = await _client.PutAsync($"/gerbils/{id}",
|
||||||
|
JsonContent.Create(new { status = "ForSale" }));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
// Assert: re-read and check all fields survive
|
||||||
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("ForSale", GetStr(json, "status"));
|
||||||
|
Assert.Equal("ForSale-Tier", GetStr(json, "name"));
|
||||||
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CharacterSave_does_not_wipe_other_fields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var id = await CreateFullGerbil("Charakter-Tier");
|
||||||
|
|
||||||
|
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
|
||||||
|
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
characterTraits = new[] { "handzahm" },
|
||||||
|
characterNote = "Neue Notiz",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
// Assert: name / genotype / originBreeder / status / externalRef all intact
|
||||||
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("Active", GetStr(json, "status"));
|
||||||
|
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
|
||||||
|
Assert.Equal("female", GetStr(json, "gender"));
|
||||||
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
|
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||||
|
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditForm_omitting_import_fields_keeps_them()
|
||||||
|
{
|
||||||
|
// Arrange: gerbil with import-set fields
|
||||||
|
var id = await CreateFullGerbil("EditForm-Tier");
|
||||||
|
|
||||||
|
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
|
||||||
|
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||||
|
{
|
||||||
|
name = "EditForm-Tier (umbenannt)",
|
||||||
|
gender = "female",
|
||||||
|
genotype = "aa CC DD ee GG PP spsp rere",
|
||||||
|
notes = "Aktualisierte Notiz",
|
||||||
|
}));
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||||
|
|
||||||
|
// Assert: name updated, import fields survive
|
||||||
|
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||||
|
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_with_minimal_body_uses_safe_defaults()
|
||||||
|
{
|
||||||
|
// A minimal POST (only name) must still produce a valid gerbil
|
||||||
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
|
||||||
|
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||||
|
Assert.Equal("Active", GetStr(json, "status"));
|
||||||
|
// characterTraits defaults to [] on create when not provided
|
||||||
|
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,10 +69,11 @@ public class SiteRendererTests
|
|||||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
["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();
|
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();
|
var photos = new JsonArray();
|
||||||
if (photo is not null) photos.Add(photo);
|
if (photo is not null) photos.Add(photo);
|
||||||
@@ -81,6 +82,9 @@ public class SiteRendererTests
|
|||||||
["name"] = name,
|
["name"] = name,
|
||||||
["farbschlag"] = farbe,
|
["farbschlag"] = farbe,
|
||||||
["group"] = group,
|
["group"] = group,
|
||||||
|
["gender"] = gender,
|
||||||
|
["dateOfBirth"] = dob,
|
||||||
|
["characterNote"] = note,
|
||||||
["photos"] = photos,
|
["photos"] = photos,
|
||||||
["aiSaleText"] = (JsonNode?)null,
|
["aiSaleText"] = (JsonNode?)null,
|
||||||
});
|
});
|
||||||
@@ -92,6 +96,7 @@ public class SiteRendererTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -211,16 +216,52 @@ public class SiteRendererTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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",
|
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"];
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
Assert.Contains("Krümel", html);
|
Assert.Contains("Krümel", html);
|
||||||
Assert.Contains("CP-Agouti", html);
|
Assert.Contains("CP-Agouti", html);
|
||||||
Assert.Contains("Großbecken", html);
|
Assert.Contains("Großbecken", html);
|
||||||
Assert.Contains("/photos/files/abc.jpg", 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]
|
[Fact]
|
||||||
@@ -283,6 +324,44 @@ public class SiteRendererTests
|
|||||||
Assert.Equal(7, files.Count);
|
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 ───────────────────────────────────────────────────────
|
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ public class ApplicationContext : DbContext
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||||
|
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||||
|
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||||
|
// support custom collation names and would fail EnsureCreated on the test host.
|
||||||
|
const string DeIcu = "de-x-icu";
|
||||||
|
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||||
|
|
||||||
modelBuilder.Entity<Gerbil>(e =>
|
modelBuilder.Entity<Gerbil>(e =>
|
||||||
{
|
{
|
||||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||||
@@ -58,6 +65,14 @@ public class ApplicationContext : DbContext
|
|||||||
// Residency defaults to true (own stock unless explicitly marked external).
|
// Residency defaults to true (own stock unless explicitly marked external).
|
||||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||||
|
|
||||||
|
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||||
|
if (isNpgsql)
|
||||||
|
{
|
||||||
|
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||||
|
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||||
|
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||||
|
}
|
||||||
|
|
||||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||||
@@ -81,6 +96,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(g => g.ColorVariety).WithMany()
|
e.HasOne(g => g.ColorVariety).WithMany()
|
||||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
|
||||||
|
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
|
||||||
|
e.HasIndex(g => g.ExternalRef)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Litter>(e =>
|
modelBuilder.Entity<Litter>(e =>
|
||||||
@@ -89,6 +110,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
e.HasOne(l => l.Mother).WithMany()
|
e.HasOne(l => l.Mother).WithMany()
|
||||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// DB-5: ExternalRef = source litter id from extract.py.
|
||||||
|
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||||
|
e.HasIndex(l => l.ExternalRef)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<HealthRecord>(e =>
|
modelBuilder.Entity<HealthRecord>(e =>
|
||||||
@@ -170,6 +197,15 @@ public class ApplicationContext : DbContext
|
|||||||
modelBuilder.Entity<MailSettings>()
|
modelBuilder.Entity<MailSettings>()
|
||||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||||
|
|
||||||
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
|
if (isNpgsql)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ColorVariety>()
|
||||||
|
.Property(v => v.Name).UseCollation(DeIcu);
|
||||||
|
modelBuilder.Entity<Contact>()
|
||||||
|
.Property(c => c.Name).UseCollation(DeIcu);
|
||||||
|
}
|
||||||
|
|
||||||
SeedColorVarieties(modelBuilder);
|
SeedColorVarieties(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +229,15 @@ public class ApplicationContext : DbContext
|
|||||||
(6, "kontakt", "Kontakt"),
|
(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 pageRows = new List<Page>();
|
||||||
var blockRows = new List<Block>();
|
var blockRows = new List<Block>();
|
||||||
foreach (var p in pages)
|
foreach (var p in pages)
|
||||||
@@ -211,6 +256,12 @@ public class ApplicationContext : DbContext
|
|||||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
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<Page>().HasData(pageRows);
|
||||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||||
@@ -231,6 +282,10 @@ public class ApplicationContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
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 =
|
(string Name, string Genotype)[] catalog =
|
||||||
{
|
{
|
||||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
||||||
@@ -294,6 +349,12 @@ public class ApplicationContext : DbContext
|
|||||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
||||||
("CP-Blaufuchs", "AA cchmcchm 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"),
|
("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];
|
var rows = new ColorVariety[catalog.Length];
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
private static string RenderAbgabetiereList(JsonObject d)
|
private static string RenderAbgabetiereList(JsonObject d)
|
||||||
{
|
{
|
||||||
var intro = Str(d["intro"]);
|
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");
|
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(intro))
|
if (!string.IsNullOrWhiteSpace(intro))
|
||||||
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
if (animals.Count == 0)
|
if (animals.Count == 0)
|
||||||
{
|
{
|
||||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
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");
|
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
||||||
foreach (var node in animals)
|
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;
|
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
||||||
var name = H(Str(a["name"]) ?? "");
|
foreach (var (url, name) in gallery)
|
||||||
var farbe = H(Str(a["farbschlag"]) ?? "");
|
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
||||||
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>\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");
|
sb.Append("</section>\n");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
<p>© {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>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
["abgabebedingungen"] = "Abgabebedingungen",
|
["abgabebedingungen"] = "Abgabebedingungen",
|
||||||
["farben-genetik"] = "Farben & Genetik",
|
["farben-genetik"] = "Farben & Genetik",
|
||||||
["kontakt"] = "Kontakt",
|
["kontakt"] = "Kontakt",
|
||||||
|
["impressum"] = "Impressum",
|
||||||
|
["datenschutz"] = "Datenschutz",
|
||||||
};
|
};
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
internal static string SiteCss() => """
|
internal static string SiteCss() => """
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #fdf8f3;
|
--color-bg: #fdf7f0;
|
||||||
--color-surface: #fff;
|
--color-surface: #fff;
|
||||||
--color-text: #2c2c2c;
|
--color-surface-warm: #fef9f4;
|
||||||
--color-muted: #6b6b6b;
|
--color-text: #2a2118;
|
||||||
--color-accent: #c0392b;
|
--color-muted: #7a6d62;
|
||||||
--color-border: #e0d8d0;
|
--color-accent: #b5331a;
|
||||||
--font-body: system-ui, sans-serif;
|
--color-accent-hover: #8f2815;
|
||||||
--max-w: 860px;
|
--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 {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.65;
|
line-height: 1.7;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
a { color: var(--color-accent); text-decoration: none; }
|
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; }
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
|
p { margin-bottom: .75rem; }
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 2px solid var(--color-border);
|
||||||
padding: .75rem 1rem;
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: stretch;
|
||||||
align-items: center;
|
gap: 0;
|
||||||
gap: .5rem 1.5rem;
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
.site-logo {
|
.site-logo {
|
||||||
|
font-family: var(--font-body);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.15rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
display: flex;
|
||||||
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
align-items: center;
|
||||||
.site-nav-link {
|
padding-right: 1.5rem;
|
||||||
font-size: .9rem;
|
border-right: 1px solid var(--color-border);
|
||||||
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;
|
|
||||||
text-decoration: none;
|
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 ── */
|
/* ── Main ── */
|
||||||
.site-main {
|
.site-main {
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
margin: 2rem auto;
|
margin: 2.5rem auto;
|
||||||
padding: 0 1rem 3rem;
|
padding: 0 1rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid var(--color-border);
|
background: var(--color-surface);
|
||||||
padding: 1.5rem 1rem;
|
border-top: 2px solid var(--color-border);
|
||||||
|
padding: 1.75rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
color: var(--color-muted);
|
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 blocks ── */
|
||||||
.cms-heading { margin: 1.5rem 0 .5rem; }
|
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
||||||
h1.cms-heading { font-size: 1.8rem; }
|
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
||||||
h2.cms-heading { font-size: 1.4rem; }
|
h2.cms-heading { font-size: 1.5rem; }
|
||||||
|
h3.cms-heading { font-size: 1.2rem; }
|
||||||
.cms-richtext { margin: 1rem 0; }
|
.cms-richtext { margin: 1rem 0; }
|
||||||
.cms-richtext p { margin-bottom: .75rem; }
|
.cms-richtext p { margin-bottom: .75rem; }
|
||||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||||
.cms-richtext li { margin-bottom: .3rem; }
|
.cms-richtext li { margin-bottom: .35rem; }
|
||||||
.cms-image { margin: 1.5rem 0; }
|
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
||||||
.cms-image img { border-radius: 6px; }
|
.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 {
|
.cms-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
gap: .75rem;
|
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 {
|
.cms-contact {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-family: var(--font-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .4rem;
|
gap: .5rem;
|
||||||
margin: 1rem 0;
|
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 ── */
|
/* ── Abgabetiere — group cards ── */
|
||||||
.cms-abgabe { margin: 1rem 0; }
|
.cms-abgabe { margin: 1.25rem 0; }
|
||||||
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
||||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||||
.cms-animal-grid {
|
|
||||||
display: grid;
|
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 1.25rem;
|
.cms-group-card {
|
||||||
}
|
|
||||||
.cms-animal-card {
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
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) {
|
/* availability badge */
|
||||||
h1.cms-heading { font-size: 1.4rem; }
|
.cms-badge {
|
||||||
.cms-animal-grid { grid-template-columns: 1fr; }
|
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; }
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
{
|
{
|
||||||
g.Id,
|
g.Id,
|
||||||
g.Name,
|
g.Name,
|
||||||
|
g.Gender,
|
||||||
|
g.DateOfBirth,
|
||||||
|
g.CharacterNote,
|
||||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||||
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
arr.Add(new JsonObject
|
arr.Add(new JsonObject
|
||||||
{
|
{
|
||||||
["name"] = a.Name,
|
["name"] = a.Name,
|
||||||
|
["gender"] = a.Gender.ToString(),
|
||||||
|
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
||||||
|
["characterNote"] = a.CharacterNote,
|
||||||
["farbschlag"] = a.Farbschlag,
|
["farbschlag"] = a.Farbschlag,
|
||||||
["group"] = a.Group,
|
["group"] = a.Group,
|
||||||
["photos"] = photoArr,
|
["photos"] = photoArr,
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
// Request DTOs -----------------------------------------------------------
|
// Request DTOs -----------------------------------------------------------
|
||||||
|
|
||||||
public record GerbilInput(
|
public record GerbilInput(
|
||||||
string Name,
|
string? Name,
|
||||||
Gender Gender,
|
Gender? Gender,
|
||||||
GerbilStatus? Status,
|
GerbilStatus? Status,
|
||||||
Guid? LitterId,
|
Guid? LitterId,
|
||||||
Guid? OriginContactId,
|
Guid? OriginContactId,
|
||||||
|
|||||||
@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||||
Apply(g, input, isCreate: true);
|
Apply(g, input, isCreate: true);
|
||||||
db.Gerbils.Add(g);
|
db.Gerbils.Add(g);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /gerbils/{id}
|
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (g is null) return TypedResults.NotFound();
|
if (g is null) return TypedResults.NotFound();
|
||||||
g.Name = input.Name;
|
|
||||||
Apply(g, input, isCreate: false);
|
Apply(g, input, isCreate: false);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||||
|
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||||
|
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||||
|
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||||
|
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||||
|
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||||
{
|
{
|
||||||
g.Gender = i.Gender;
|
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||||
|
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||||
g.LitterId = i.LitterId;
|
g.LitterId = i.LitterId ?? g.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId;
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||||
g.EnclosureId = i.EnclosureId;
|
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||||
g.ColorVarietyId = i.ColorVarietyId;
|
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||||
g.DateOfBirth = i.DateOfBirth;
|
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||||
g.DateOfDeath = i.DateOfDeath;
|
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||||
g.CauseOfDeath = i.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||||
g.Genotype = i.Genotype;
|
g.Genotype = i.Genotype ?? g.Genotype;
|
||||||
g.Notes = i.Notes;
|
g.Notes = i.Notes ?? g.Notes;
|
||||||
g.ImportSource = i.ImportSource;
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef;
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||||
g.OriginBreeder = i.OriginBreeder;
|
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||||
g.CharacterNote = i.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using GerbilManagerWebAPI.Names;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||||
|
///
|
||||||
|
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||||
|
/// frontend maps exactly this code to its German disabled-state hint.
|
||||||
|
/// </summary>
|
||||||
|
public static class NamesEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/names/suggest", async (
|
||||||
|
string? letter,
|
||||||
|
string? gender,
|
||||||
|
string? usages,
|
||||||
|
int count,
|
||||||
|
NameSuggestionService service,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||||
|
return result.Status switch
|
||||||
|
{
|
||||||
|
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||||
|
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||||
|
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||||
|
_ => Results.Json(
|
||||||
|
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.WithTags("Names");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +1,40 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
<ItemGroup>
|
||||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
</PackageReference>
|
||||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
<ItemGroup>
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||||
</ItemGroup>
|
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||||
</ItemGroup>
|
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
ResidencySummary? Residency = null);
|
ResidencySummary? Residency = null);
|
||||||
|
|
||||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
||||||
|
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
|
||||||
|
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
|
||||||
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
|
||||||
|
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
|
||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
@@ -98,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int AlreadyImported,
|
int AlreadyImported,
|
||||||
QuarantineSummary Quarantined,
|
QuarantineSummary Quarantined,
|
||||||
int ParentLinksFromChart = 0,
|
int ParentLinksFromChart = 0,
|
||||||
int ConflictsResolvedByDecision = 0);
|
int ConflictsResolvedByDecision = 0,
|
||||||
|
int FarbschlagDerivedFromGenotype = 0);
|
||||||
|
|
||||||
public sealed record QuarantineSummary(
|
public sealed record QuarantineSummary(
|
||||||
int Conflicts,
|
int Conflicts,
|
||||||
|
|||||||
@@ -71,17 +71,21 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
else if (conf == "niedrig") dateOnly++;
|
else if (conf == "niedrig") dateOnly++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
// existing rows (idempotency).
|
||||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
|
||||||
var existingGerbilExtRefs = await _db.Gerbils
|
var existingGerbilExtRefs = await _db.Gerbils
|
||||||
.Where(g => g.ExternalRef != null)
|
.Where(g => g.ExternalRef != null)
|
||||||
.Select(g => g.ExternalRef!).ToListAsync();
|
.Select(g => g.ExternalRef!).ToListAsync();
|
||||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||||
|
|
||||||
var existingLitterKeys = await _db.Litters
|
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||||
var existingLitterKeySet = existingLitterKeys
|
var existingLitterData = await _db.Litters
|
||||||
|
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||||
|
var existingLitterKeySet = existingLitterData
|
||||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||||
|
var existingLitterExtRefSet = existingLitterData
|
||||||
|
.Where(x => x.ExternalRef != null)
|
||||||
|
.Select(x => x.ExternalRef!).ToHashSet();
|
||||||
|
|
||||||
// colour-variety name -> id (case-insensitive)
|
// colour-variety name -> id (case-insensitive)
|
||||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||||
@@ -94,10 +98,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
|
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||||
|
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
// so their existingLitterKeySet key was always "" → they were always counted as
|
// so skip them early — they can never be created or linked to animals.
|
||||||
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
|
||||||
// Fix: skip undated litters early — they can never be created or linked to animals.
|
|
||||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
@@ -107,7 +111,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||||
|
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||||
|
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||||
|
{ littersExisting++; continue; }
|
||||||
|
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
@@ -122,6 +129,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
|
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8)
|
if (samples.Count < 8)
|
||||||
@@ -137,31 +145,71 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||||
var existingRows = await _db.Gerbils
|
var existingRows = await _db.Gerbils
|
||||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
|
||||||
var gidByNameDob = new Dictionary<string, Guid>();
|
var gidByNameDob = new Dictionary<string, Guid>();
|
||||||
foreach (var g in existingRows)
|
foreach (var g in existingRows)
|
||||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||||
|
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
|
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||||
|
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
|
||||||
|
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
|
.ToDictionary(g => g.ExternalRef!, g => g.Id);
|
||||||
|
|
||||||
|
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
|
||||||
|
var varietiesWithGeno = await _db.ColorVarieties
|
||||||
|
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
|
||||||
|
|
||||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||||
var plan = new List<AnimalPlan>();
|
var plan = new List<AnimalPlan>();
|
||||||
|
int fbDerivedFromGenotype = 0;
|
||||||
foreach (var a in loadable)
|
foreach (var a in loadable)
|
||||||
{
|
{
|
||||||
bool exists = existingGerbilSet.Contains(a.Id);
|
bool exists = existingGerbilSet.Contains(a.Id);
|
||||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
|
||||||
|
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
|
||||||
|
Guid gid;
|
||||||
|
if (exists)
|
||||||
|
{
|
||||||
|
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
|
||||||
|
{
|
||||||
|
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
|
||||||
|
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else gid = Guid.NewGuid();
|
||||||
|
|
||||||
Guid? wurfLitterId = null;
|
Guid? wurfLitterId = null;
|
||||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||||
wurfLitterId = lid;
|
wurfLitterId = lid;
|
||||||
|
|
||||||
|
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
|
||||||
|
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
|
||||||
Guid? colorVarietyId = null;
|
Guid? colorVarietyId = null;
|
||||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||||
foreach (var fb in fbCandidates)
|
foreach (var fb in fbCandidates)
|
||||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||||
{ colorVarietyId = vid; break; }
|
{ colorVarietyId = vid; break; }
|
||||||
|
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
|
||||||
|
{
|
||||||
|
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
|
||||||
|
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
|
||||||
|
// would match any catalog entry via wildcards and produce false positives.
|
||||||
|
var composed = ComposeGenotype(a.Genotype);
|
||||||
|
if (!composed.Contains("??"))
|
||||||
|
foreach (var v in varietiesWithGeno)
|
||||||
|
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||||
|
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
|
||||||
|
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
|
||||||
|
}
|
||||||
|
|
||||||
var gender = InferGender(a, sireNames, damNames);
|
var gender = InferGender(a, sireNames, damNames);
|
||||||
var norm = Normalize(StripZucht(a.Name));
|
var norm = Normalize(StripZucht(a.Name));
|
||||||
@@ -169,7 +217,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||||
|
|
||||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
||||||
|
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||||
@@ -460,6 +509,78 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
|
||||||
|
// Counts already-imported animals where the current extract matched a DIFFERENT
|
||||||
|
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
|
||||||
|
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||||
|
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||||
|
|
||||||
|
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
|
||||||
|
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
|
||||||
|
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
|
||||||
|
{
|
||||||
|
var noColor = await _db.Gerbils
|
||||||
|
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
|
||||||
|
.Select(g => new { g.Id, g.Genotype })
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var g in noColor)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
|
||||||
|
Guid? derivedVid = null;
|
||||||
|
foreach (var v in varietiesWithGeno)
|
||||||
|
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||||
|
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
|
||||||
|
{ derivedVid = v.Id; break; }
|
||||||
|
if (derivedVid is null) continue;
|
||||||
|
fbDerivedFromGenotype++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||||
|
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||||
|
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||||
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
|
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
||||||
|
int herkunftBackfilled = 0;
|
||||||
|
{
|
||||||
|
var needsHerkunft = await _db.Gerbils
|
||||||
|
.Where(g => g.OriginBreeder == null && g.IsResident)
|
||||||
|
.Select(g => new { g.Id, g.LitterId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var g in needsHerkunft)
|
||||||
|
{
|
||||||
|
herkunftBackfilled++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
// Prefer the OriginBreeder of an existing parent (father first, then mother).
|
||||||
|
string? derived = null;
|
||||||
|
if (g.LitterId is not null)
|
||||||
|
{
|
||||||
|
var parentIds = await _db.Litters
|
||||||
|
.Where(l => l.Id == g.LitterId)
|
||||||
|
.Select(l => new { l.FatherId, l.MotherId })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (parentIds?.FatherId is Guid fid)
|
||||||
|
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
|
||||||
|
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||||
|
if (derived is null && parentIds?.MotherId is Guid mid)
|
||||||
|
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
|
||||||
|
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||||
|
if (row is not null && row.OriginBreeder is null)
|
||||||
|
row.OriginBreeder = derived ?? DefaultClanBreeder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (littersWithoutDate > 0)
|
if (littersWithoutDate > 0)
|
||||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
@@ -469,9 +590,15 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (parentFksBackfilled > 0)
|
if (parentFksBackfilled > 0)
|
||||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
|
if (herkunftBackfilled > 0)
|
||||||
|
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||||
|
if (farbschlagWouldRebackfill > 0)
|
||||||
|
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
if (conflictsResolvedByDecision > 0)
|
||||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||||
|
if (fbDerivedFromGenotype > 0)
|
||||||
|
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
|
||||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
@@ -480,11 +607,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
parentLinksAdded, conflictsResolvedByDecision),
|
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||||
|
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||||
}
|
}
|
||||||
|
|
||||||
private T? Load<T>(string file)
|
private T? Load<T>(string file)
|
||||||
@@ -517,6 +645,23 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||||
|
|
||||||
|
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
|
||||||
|
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
|
||||||
|
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
|
||||||
|
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
|
||||||
|
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
|
||||||
|
{
|
||||||
|
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (a.Length < 8 || c.Length < 8) return false;
|
||||||
|
for (int i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
if (a[i] == "??" || c[i] == "??") continue;
|
||||||
|
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||||
{
|
{
|
||||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||||
@@ -574,7 +719,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||||
private sealed record AnimalPlan(
|
private sealed record AnimalPlan(
|
||||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
|
||||||
|
Guid? CurrentColorVarietyId = null);
|
||||||
|
|
||||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||||
|
|||||||
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1337
GerbilManagerWebAPI/Migrations/20260606134002_ReseedColorVarietiesGen3g.Designer.cs
generated
Normal file
1337
GerbilManagerWebAPI/Migrations/20260606134002_ReseedColorVarietiesGen3g.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGermanCollation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalRef",
|
||||||
|
table: "Litters",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginBreeder",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "NameSearch",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "ColorVarieties",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Litters_ExternalRef",
|
||||||
|
table: "Litters",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Litters_ExternalRef",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalRef",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginBreeder",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true,
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "NameSearch",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true,
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "ColorVarieties",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterLetter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LitterLetter",
|
||||||
|
table: "Litters",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LitterLetter",
|
||||||
|
table: "Litters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Order = 1,
|
Order = 1,
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
||||||
Type = "AbgabetiereList"
|
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"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
@@ -605,6 +638,41 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel",
|
Name = "CP-Orangeschimmel",
|
||||||
SortOrder = 60
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -622,7 +690,8 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -710,16 +779,19 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("NameSearch")
|
b.Property<string>("NameSearch")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("OriginBreeder")
|
b.Property<string>("OriginBreeder")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<Guid?>("OriginContactId")
|
b.Property<Guid?>("OriginContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
@@ -740,6 +812,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasIndex("EnclosureId");
|
b.HasIndex("EnclosureId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("LitterId");
|
b.HasIndex("LitterId");
|
||||||
|
|
||||||
b.HasIndex("OriginContactId");
|
b.HasIndex("OriginContactId");
|
||||||
@@ -823,9 +899,15 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalRef")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("FatherId")
|
b.Property<Guid?>("FatherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LitterLetter")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("MotherId")
|
b.Property<Guid?>("MotherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -844,6 +926,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("FatherId");
|
b.HasIndex("FatherId");
|
||||||
|
|
||||||
b.HasIndex("MotherId");
|
b.HasIndex("MotherId");
|
||||||
@@ -989,6 +1075,20 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Slug = "kontakt",
|
Slug = "kontakt",
|
||||||
Status = "Published",
|
Status = "Published",
|
||||||
Title = "Kontakt"
|
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"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,5 +25,14 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||||
public string? PairingCode { get; set; }
|
public string? PairingCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
|
||||||
|
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||||
|
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||||
|
public string? ExternalRef { get; set; }
|
||||||
|
|
||||||
|
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||||
|
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||||
|
public string? LitterLetter { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Names
|
||||||
|
{
|
||||||
|
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
||||||
|
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
||||||
|
}
|
||||||
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Ai;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Names
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
|
||||||
|
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
|
||||||
|
/// Returns NotConfigured when the AI section is missing — callers map to 503.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
|
||||||
|
{
|
||||||
|
private readonly OpenAiChatClient _client = new(http, options);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<NameSuggestionResult> SuggestAsync(
|
||||||
|
string? letter, string? gender, string? usages, int count,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var aiResult = await _client.CompleteAsync(
|
||||||
|
BuildSystemPrompt(),
|
||||||
|
BuildUserPrompt(letter, gender, usages, count),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (aiResult.Status == AiCallStatus.NotConfigured)
|
||||||
|
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
|
||||||
|
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
|
||||||
|
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
|
||||||
|
|
||||||
|
var suggestions = ParseSuggestions(aiResult.Text);
|
||||||
|
return suggestions is null
|
||||||
|
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
|
||||||
|
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildSystemPrompt() =>
|
||||||
|
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||||
|
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||||
|
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||||
|
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
||||||
|
|
||||||
|
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"Schlage {count} Rennmaus-Namen vor");
|
||||||
|
if (!string.IsNullOrWhiteSpace(letter))
|
||||||
|
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
|
||||||
|
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||||
|
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||||
|
if (!string.IsNullOrWhiteSpace(usages))
|
||||||
|
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||||
|
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||||
|
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||||
|
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||||
|
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
||||||
|
/// its JSON output, then deserialises the array.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<NameSuggestion>? ParseSuggestions(string raw)
|
||||||
|
{
|
||||||
|
var text = raw.Trim();
|
||||||
|
|
||||||
|
// Strip ```json ... ``` or ``` ... ``` fences.
|
||||||
|
if (text.StartsWith("```", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var firstNewline = text.IndexOf('\n');
|
||||||
|
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
|
||||||
|
if (text.EndsWith("```", StringComparison.Ordinal))
|
||||||
|
text = text[..^3].TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the JSON array bounds defensively.
|
||||||
|
var start = text.IndexOf('[');
|
||||||
|
var end = text.LastIndexOf(']');
|
||||||
|
if (start < 0 || end <= start) return null;
|
||||||
|
text = text[start..(end + 1)];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
||||||
|
|
||||||
|
public sealed record NameSuggestionResult(
|
||||||
|
NameSuggestionStatus Status,
|
||||||
|
List<NameSuggestion>? Suggestions,
|
||||||
|
string? Error);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using GerbilManagerWebAPI.Endpoints;
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
@@ -44,9 +45,22 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
|||||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||||
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||||
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||||
builder.Services.AddDataProtection();
|
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||||
|
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
|
||||||
|
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
|
||||||
|
{
|
||||||
|
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
|
||||||
|
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
|
||||||
|
Directory.CreateDirectory(keyRingPath);
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
|
||||||
|
.SetApplicationName("GerbilManager");
|
||||||
|
}
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||||
@@ -89,6 +103,7 @@ app.MapSettingsEndpoints();
|
|||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
app.MapCmsEndpoints();
|
app.MapCmsEndpoints();
|
||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
|
app.MapNamesEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
_Stand: 2026-06-06._
|
_Stand: 2026-06-06._
|
||||||
|
|
||||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
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
|
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
|
||||||
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
|
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
|
||||||
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,11 +20,11 @@ Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|||||||
|
|
||||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
| ~~A1~~ | ✅ **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
|
||||||
| 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 |
|
| ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
|
||||||
| 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) |
|
| 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) |
|
| 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)
|
## 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)
|
## 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.
|
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`.
|
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
||||||
@@ -126,10 +126,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
|||||||
| Tier | Konkreter Konflikt — was stimmt? |
|
| Tier | Konkreter Konflikt — was stimmt? |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
|
||||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
|
||||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
|
||||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
|
||||||
|
|
||||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ sagen, was ergänzt oder gestrichen werden soll:**
|
|||||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
||||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
||||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
| 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) |
|
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager Produktionskonfiguration
|
# GerbilManager Produktionskonfiguration
|
||||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||||
|
|
||||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||||
@@ -15,9 +15,15 @@ TAG=latest
|
|||||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||||
|
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||||
|
KEYS_PATH=/mnt/SSD/gerbil/keys
|
||||||
|
|
||||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||||
BACKUP_KEEP_DAYS=7
|
BACKUP_KEEP_DAYS=7
|
||||||
|
|
||||||
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
|
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||||
ANTHROPIC_API_KEY=
|
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||||
|
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||||
|
AI__BaseUrl=
|
||||||
|
AI__ApiKey=
|
||||||
|
AI__Model=gemini-flash-latest
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager — TrueNAS Custom Application
|
# GerbilManager — TrueNAS Custom Application
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Vor dem ersten Start:
|
# Vor dem ersten Start:
|
||||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||||
@@ -40,10 +40,17 @@ services:
|
|||||||
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
||||||
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
||||||
Photos__RootPath: /data/photos
|
Photos__RootPath: /data/photos
|
||||||
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
|
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
|
||||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
DataProtection__KeyRingPath: /data/keys
|
||||||
|
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
|
||||||
|
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
|
||||||
|
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
|
||||||
|
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||||
|
AI__ApiKey: "${AI__ApiKey:-}"
|
||||||
|
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||||
volumes:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
|
- keys:/data/keys
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -101,6 +108,14 @@ volumes:
|
|||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||||
|
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||||
|
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||||
|
keys:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||||
backups:
|
backups:
|
||||||
driver: local
|
driver: local
|
||||||
driver_opts:
|
driver_opts:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||||
|
|
||||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||||
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
|||||||
|
|
||||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
|
||||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||||
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
|||||||
```powershell
|
```powershell
|
||||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
$env:AI__ApiKey = '<schlüssel>'
|
$env:AI__ApiKey = '<schlüssel>'
|
||||||
$env:AI__Model = 'gemini-2.0-flash'
|
$env:AI__Model = 'gemini-flash-latest'
|
||||||
```
|
```
|
||||||
|
|
||||||
oder per user-secrets im API-Projekt:
|
oder per user-secrets im API-Projekt:
|
||||||
@@ -46,5 +46,5 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||||
AI__Model: gemini-2.0-flash
|
AI__Model: gemini-flash-latest
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ npm run build
|
|||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Farbschlag-Katalog (AR-5)
|
||||||
|
|
||||||
|
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
|
||||||
|
Nach jeder Änderung dort den Generator laufen lassen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run gen:catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugt zwei Artefakte und committet beide:
|
||||||
|
|
||||||
|
| Datei | Notation | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
|
||||||
|
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
|
||||||
|
|
||||||
|
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
|
||||||
|
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
|
||||||
|
|
||||||
## E2E-Tests (QA-1, Playwright)
|
## E2E-Tests (QA-1, Playwright)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -56,4 +75,4 @@ npx playwright test --project=phone # nur Smartphone-Viewport (390px)
|
|||||||
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
||||||
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
||||||
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
||||||
`src/strings/de.ts`.
|
`src/strings/de.ts`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
|
|||||||
test.describe('Becken', () => {
|
test.describe('Becken', () => {
|
||||||
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/becken')
|
await page.goto('/gehege')
|
||||||
await page.getByRole('link', { name: /Großbecken/ }).click()
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
||||||
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).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 }) => {
|
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/becken')
|
await page.goto('/gehege')
|
||||||
await page.getByRole('link', { name: /Großbecken/ }).click()
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
acceptNextDialog(page)
|
acceptNextDialog(page)
|
||||||
await page.getByRole('button', { name: tb.delete.action }).click()
|
await page.getByRole('button', { name: tb.delete.action }).click()
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function skipUnlessLive() {
|
|||||||
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||||
*/
|
*/
|
||||||
export async function gotoSection(page: Page, label: string) {
|
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
|
// App noch nicht geladen (about:blank) -> erst zur Startseite
|
||||||
if (!(await nav.isVisible())) {
|
if (!(await nav.isVisible())) {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|||||||
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* QA-REGRESSION-1: GEHEGE-RENAME Lock-in.
|
||||||
|
*
|
||||||
|
* Sichert das Verhalten von drei GEHEGE-RENAME-Änderungen ab, die leicht still
|
||||||
|
* brechen können (Redirects, Nav-Einträge, Landing-URL):
|
||||||
|
* (a) /becken/* → /gehege/* Redirect (bookmarks / externe Links)
|
||||||
|
* (b) Nav zeigt 'Gehege' + 🏜️, kein 'Becken', kein 'Start'-Eintrag
|
||||||
|
* (c) '/' (Index) landet auf /rennmaeuse (nicht mehr auf einer Startseite)
|
||||||
|
*
|
||||||
|
* Bereits abgedeckt (NICHT dupliziert):
|
||||||
|
* - gotoSection(de.nav.enclosures) navigiert zu Gehege-Heading: navigation.spec.ts:19-20
|
||||||
|
* - CRUD + 409 auf /gehege: becken-kontakte.spec.ts
|
||||||
|
* - Wurf-Link + Herkunft: tiere.spec.ts:101-128
|
||||||
|
*/
|
||||||
|
import { de, expect, test } from './fixtures'
|
||||||
|
|
||||||
|
// ── (a) /becken/* → /gehege/* Redirect ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: /becken leitet auf /gehege weiter (bookmark-safe)', async ({ page }) => {
|
||||||
|
await page.goto('/becken')
|
||||||
|
// React Router BeckenRedirect navigiert → /gehege; Gehege-Heading muss sichtbar sein
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/gehege$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: /becken/neu leitet auf /gehege/neu weiter (deep redirect)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/becken/neu')
|
||||||
|
// Neues-Gehege-Formular muss sichtbar sein, URL muss /gehege/neu sein
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.form?.createTitle ?? de.pages.becken.newButton })).toBeVisible({ timeout: 6_000 })
|
||||||
|
await expect(page).toHaveURL(/\/gehege\/neu$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── (b) Nav: 'Gehege' ✓, 'Becken' ✗, 'Start' ✗ ──────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: Nav hat "Gehege"-Eintrag mit Terrarium-Icon, kein "Becken"', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
|
|
||||||
|
// Gehege-Link soll vorhanden sein (via „Mehr"-Blatt auf Phone oder direkt auf Desktop)
|
||||||
|
// Wir öffnen „Mehr" um sekundäre Links sichtbar zu machen
|
||||||
|
const moreBtn = nav.getByRole('button', { name: de.nav.more })
|
||||||
|
if (await moreBtn.isVisible()) {
|
||||||
|
await moreBtn.click()
|
||||||
|
}
|
||||||
|
await expect(nav.getByRole('link', { name: de.nav.enclosures })).toBeVisible()
|
||||||
|
// "Becken" als separater Nav-Eintrag darf nicht mehr existieren
|
||||||
|
await expect(nav.getByRole('link', { name: 'Becken', exact: true })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: Nav hat keinen "Start"-Eintrag mehr (de.nav.home entfernt)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
// Warten bis die Tierliste geladen ist, dann Nav prüfen
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
|
// 'Start' (de.nav.home wurde entfernt) darf kein Nav-Link mehr sein
|
||||||
|
await expect(nav.getByRole('link', { name: 'Start', exact: true })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── (c) '/' landet auf /rennmaeuse ──────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: "/" leitet auf /rennmaeuse weiter — keine Startseite mehr', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/')
|
||||||
|
// URL muss /rennmaeuse sein (Navigate replace)
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||||
|
// Tierliste-Heading sichtbar — nicht die alte Startseite
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
// Alten Willkommen-Text darf es nicht mehr geben (de.pages.home wurde entfernt)
|
||||||
|
await expect(page.getByText('Willkommen')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: 404-Seite "Zur Startseite" landet weiterhin auf Tierliste', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/diese-seite-gibt-es-nicht')
|
||||||
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
|
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||||
|
// Nach Klick auf "Zur Startseite" → '/' → redirect → /rennmaeuse
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||||
|
})
|
||||||
302
gerbil-manager-web/e2e/live-data.spec.ts
Normal file
302
gerbil-manager-web/e2e/live-data.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -40,18 +40,13 @@ test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)',
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
skipUnlessLive()
|
skipUnlessLive()
|
||||||
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
|
// Direkt zu einem Wurf mit beiden Eltern navigieren (Re-Import #2.5, stabil).
|
||||||
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
|
// Wurf (aus Diagramm) 2026-02-10 — fatherId + motherId beide gesetzt.
|
||||||
await gotoSection(page, de.nav.litters)
|
// Kein nth()-Selektor mehr: bei 865 Würfen + Pager-Nav nicht deterministisch.
|
||||||
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
|
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
|
||||||
|
|
||||||
// Mindestens 1 Wurf in der Liste; ersten öffnen.
|
// Detailseite: Elterntier-Abschnitt immer sichtbar (kein bedingtes Rendering)
|
||||||
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
|
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
await expect(firstLink).toBeVisible()
|
|
||||||
await firstLink.click()
|
|
||||||
|
|
||||||
// Detailseite: Elterntier-Abschnitt ist sichtbar
|
|
||||||
await expect(page.getByText(tl.detail.parents)).toBeVisible()
|
|
||||||
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
|
// 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 })
|
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 201, contract)
|
return json(route, 201, contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FEAT-NAMEGEN: /names/suggest
|
||||||
|
if (path === '/names/suggest' && method === 'GET') {
|
||||||
|
if (!db.namesConfigured) {
|
||||||
|
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
|
||||||
|
}
|
||||||
|
const letter = url.searchParams.get('letter')?.toUpperCase()
|
||||||
|
const allSuggestions = [
|
||||||
|
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
|
||||||
|
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
|
||||||
|
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
|
||||||
|
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
|
||||||
|
]
|
||||||
|
const result = letter
|
||||||
|
? allSuggestions.filter((s) => s.name.startsWith(letter))
|
||||||
|
: allSuggestions
|
||||||
|
return json(route, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface MockDb {
|
|||||||
// ABGABE: Verträge + KI-Inserat-Flag
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
contracts: MockContract[]
|
contracts: MockContract[]
|
||||||
saleAdConfigured: boolean
|
saleAdConfigured: boolean
|
||||||
|
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||||
|
namesConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -149,6 +151,8 @@ export function seedDb(): MockDb {
|
|||||||
enclosureId: 'enc-leer',
|
enclosureId: 'enc-leer',
|
||||||
notes: 'neugieriger Entdecker',
|
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[] = [
|
const litters: Litter[] = [
|
||||||
@@ -290,5 +294,6 @@ export function seedDb(): MockDb {
|
|||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
contracts: [],
|
contracts: [],
|
||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
|
namesConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.namegen
|
||||||
|
const tf = de.pages.gerbils.form
|
||||||
|
|
||||||
|
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Panel öffnen
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// Vorschläge laden
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf Vorschlag 'Fenrir'
|
||||||
|
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||||
|
|
||||||
|
// Panel geschlossen, Name-Feld befüllt
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||||
|
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByLabel(t.letterLabel).fill('F')
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
|
||||||
|
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||||
|
// Artemis (A) nicht sichtbar
|
||||||
|
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (mockDb) mockDb.namesConfigured = false
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { de, expect, gotoSection, test } from './fixtures'
|
|||||||
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
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 gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
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 page.goto('/diese-seite-gibt-es-nicht')
|
||||||
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
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()
|
||||||
})
|
})
|
||||||
|
|||||||
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
|
||||||
|
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
|
||||||
|
*
|
||||||
|
* Abgedeckte Änderungen:
|
||||||
|
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
|
||||||
|
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
|
||||||
|
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
|
||||||
|
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
|
||||||
|
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
|
||||||
|
*
|
||||||
|
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
|
||||||
|
*/
|
||||||
|
import { de, expect, skipUnlessLive, test } from './fixtures'
|
||||||
|
|
||||||
|
const API = 'http://localhost:5179'
|
||||||
|
|
||||||
|
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
|
||||||
|
// eef = e/ef het → E>e>ef Rank → ee[f]
|
||||||
|
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
|
||||||
|
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
|
||||||
|
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
|
||||||
|
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Rohes 'eef' darf nicht mehr im Code-Block stehen
|
||||||
|
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
|
||||||
|
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
|
||||||
|
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
|
||||||
|
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
|
||||||
|
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
|
||||||
|
// Ccchm → Cc[chm]; eef → ee[f]
|
||||||
|
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
|
||||||
|
const genoCode = page.locator('code')
|
||||||
|
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
|
||||||
|
// Rohes Storage-Format darf nicht sichtbar sein
|
||||||
|
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
|
||||||
|
|
||||||
|
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
|
||||||
|
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
|
||||||
|
|
||||||
|
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
|
||||||
|
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
|
||||||
|
await expect(originInput).toBeVisible()
|
||||||
|
await expect(originInput).toHaveValue('Kleinen Chaoten')
|
||||||
|
|
||||||
|
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
|
||||||
|
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
|
||||||
|
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
|
||||||
|
await expect(deafLabel).toBeVisible()
|
||||||
|
// Dropdown-Select im Label-Kontext
|
||||||
|
const deafSelect = deafLabel.locator('select')
|
||||||
|
await expect(deafSelect).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
|
||||||
|
|
||||||
|
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Snapshot VORHER
|
||||||
|
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||||
|
const snap = await before.json()
|
||||||
|
expect(snap.originBreeder).toBe('Kleinen Chaoten')
|
||||||
|
expect(snap.genotype).toBeTruthy()
|
||||||
|
|
||||||
|
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
|
||||||
|
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
|
||||||
|
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
|
||||||
|
data: {
|
||||||
|
name: snap.name,
|
||||||
|
gender: snap.gender,
|
||||||
|
status: snap.status,
|
||||||
|
isResident: snap.isResident,
|
||||||
|
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
|
||||||
|
expect(res.status()).toBe(204)
|
||||||
|
|
||||||
|
// Re-Fetch und vergleichen
|
||||||
|
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||||
|
const updated = await afterRes.json()
|
||||||
|
|
||||||
|
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
|
||||||
|
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
|
||||||
|
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
|
||||||
|
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
|
||||||
|
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
|
||||||
|
|
||||||
|
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
|
||||||
|
|
||||||
|
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
|
||||||
|
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
|
||||||
|
const placeholder = page.getByText('(ohne Namen)')
|
||||||
|
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
|
||||||
|
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
||||||
|
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
|
||||||
|
if (await toggle.isVisible()) await toggle.click()
|
||||||
|
}
|
||||||
|
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Stammbaum eines Tiers mit namenlosem Elternteil
|
||||||
|
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
|
||||||
|
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||||
|
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||||
|
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
|
||||||
|
const placeholder = page.getByText('(ohne Namen)')
|
||||||
|
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
|
||||||
|
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
|
||||||
|
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||||
|
if (visible) {
|
||||||
|
await expect(placeholder.first()).toBeVisible()
|
||||||
|
}
|
||||||
|
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
|
||||||
|
|
||||||
|
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/becken')
|
||||||
|
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
|||||||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||||
|
// Titel zeigt '(ohne Namen)'
|
||||||
|
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||||||
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
|
})
|
||||||
|
|||||||
@@ -98,6 +98,48 @@ test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', as
|
|||||||
await expect(page.getByText(/85\s*g/)).toBeVisible()
|
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 }) => {
|
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
@@ -107,3 +149,50 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
|
||||||
|
await page.goto('/rennmaeuse/fridolin/bearbeiten')
|
||||||
|
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
|
||||||
|
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
|
||||||
|
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
// Standard: Unbekannt (null → value='')
|
||||||
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
|
||||||
|
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||||
|
await page.goto('/rennmaeuse/fridolin')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||||
|
// Wurf K als Link sichtbar
|
||||||
|
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||||
|
await expect(litterLink).toBeVisible()
|
||||||
|
// Vater-Badge sichtbar
|
||||||
|
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||||
|
// Navigation zum Wurf funktioniert
|
||||||
|
await litterLink.first().click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
49
gerbil-manager-web/gen-seed.mts
Normal file
49
gerbil-manager-web/gen-seed.mts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Katalog-Generator — AR-5
|
||||||
|
*
|
||||||
|
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
|
||||||
|
*
|
||||||
|
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
|
||||||
|
* → Quelle für UI-Dropdowns, Frontend-Suche.
|
||||||
|
*
|
||||||
|
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
|
||||||
|
* → Quelle für künftige EF-Seed-Migrationen (Pam).
|
||||||
|
* NICHT in Bracket-Notation ändern — Backend-Parser
|
||||||
|
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
|
||||||
|
*
|
||||||
|
* Ausführen nach jeder Änderung an catalog.ts:
|
||||||
|
* npm run gen:catalog
|
||||||
|
*
|
||||||
|
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
|
||||||
|
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
|
||||||
|
*/
|
||||||
|
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
|
||||||
|
import { LOCUS_ORDER } from './src/genetics/loci.ts'
|
||||||
|
import type { Genotype } from './src/genetics/genotype.ts'
|
||||||
|
import { writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
|
||||||
|
function toInternalString(g: Genotype): string {
|
||||||
|
return LOCUS_ORDER.filter(
|
||||||
|
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
|
)
|
||||||
|
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display artefact (bracket notation) ─────────────────────────────────────
|
||||||
|
const displayPath = './src/genetics/colorVarietySeed.generated.json'
|
||||||
|
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
|
||||||
|
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
|
||||||
|
|
||||||
|
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
|
||||||
|
const backendSeed = BASE_COLORS.map((entry, i) => ({
|
||||||
|
name: entry.name,
|
||||||
|
...(entry.english !== undefined ? { english: entry.english } : {}),
|
||||||
|
canonicalGenotype: toInternalString(representativeGenotype(entry)),
|
||||||
|
sortOrder: i,
|
||||||
|
...(entry.image !== undefined ? { image: entry.image } : {}),
|
||||||
|
}))
|
||||||
|
const backendPath = './src/genetics/colorVarietySeed.backend.json'
|
||||||
|
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
|
||||||
|
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test",
|
||||||
|
"gen:catalog": "npx tsx gen-seed.mts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -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 AppShell from './components/AppShell'
|
||||||
import BeckenPage from './pages/BeckenPage'
|
import BeckenPage from './pages/BeckenPage'
|
||||||
import BeckenDetailPage from './pages/BeckenDetailPage'
|
import BeckenDetailPage from './pages/BeckenDetailPage'
|
||||||
@@ -8,7 +8,6 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
|
|||||||
import GerbilFormPage from './pages/GerbilFormPage'
|
import GerbilFormPage from './pages/GerbilFormPage'
|
||||||
import GenetikPage from './pages/GenetikPage'
|
import GenetikPage from './pages/GenetikPage'
|
||||||
import AbgabePage from './pages/AbgabePage'
|
import AbgabePage from './pages/AbgabePage'
|
||||||
import HomePage from './pages/HomePage'
|
|
||||||
import KontaktePage from './pages/KontaktePage'
|
import KontaktePage from './pages/KontaktePage'
|
||||||
import KontaktDetailPage from './pages/KontaktDetailPage'
|
import KontaktDetailPage from './pages/KontaktDetailPage'
|
||||||
import KontaktFormPage from './pages/KontaktFormPage'
|
import KontaktFormPage from './pages/KontaktFormPage'
|
||||||
@@ -28,12 +27,17 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
|||||||
import AnfragenPage from './pages/AnfragenPage'
|
import AnfragenPage from './pages/AnfragenPage'
|
||||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||||
|
|
||||||
|
function BeckenRedirect() {
|
||||||
|
const { '*': splat } = useParams()
|
||||||
|
return <Navigate to={`/gehege${splat ? `/${splat}` : ''}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<Navigate to="/rennmaeuse" replace />} />
|
||||||
<Route path="rennmaeuse">
|
<Route path="rennmaeuse">
|
||||||
<Route index element={<GerbilsPage />} />
|
<Route index element={<GerbilsPage />} />
|
||||||
<Route path="neu" element={<GerbilFormPage />} />
|
<Route path="neu" element={<GerbilFormPage />} />
|
||||||
@@ -48,7 +52,9 @@ export default function App() {
|
|||||||
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
|
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
|
{/* 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 index element={<BeckenPage />} />
|
||||||
<Route path="neu" element={<BeckenFormPage />} />
|
<Route path="neu" element={<BeckenFormPage />} />
|
||||||
<Route path=":id" element={<BeckenDetailPage />} />
|
<Route path=":id" element={<BeckenDetailPage />} />
|
||||||
|
|||||||
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
|
||||||
|
|
||||||
|
describe('buildSuggestPath', () => {
|
||||||
|
it('includes uppercased letter', () => {
|
||||||
|
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
|
||||||
|
expect(path).toContain('letter=F')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits letter when blank', () => {
|
||||||
|
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('letter=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits letter when only whitespace', () => {
|
||||||
|
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('letter=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes gender', () => {
|
||||||
|
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
|
||||||
|
expect(path).toContain('gender=male')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits gender for empty string', () => {
|
||||||
|
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('gender=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins multiple usages without encoding commas', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
|
||||||
|
expect(path).toContain('usages=norn,mythg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits usages when array is empty', () => {
|
||||||
|
const path = buildSuggestPath({ usages: [] })
|
||||||
|
expect(path).not.toContain('usages=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults count to 6', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'] })
|
||||||
|
expect(path).toContain('count=6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses provided count', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
|
||||||
|
expect(path).toContain('count=8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with /names/suggest', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'] })
|
||||||
|
expect(path).toMatch(/^\/names\/suggest\?/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NAMEGEN_USAGES', () => {
|
||||||
|
it('contains all 5 expected culture codes', () => {
|
||||||
|
const codes = NAMEGEN_USAGES.map((u) => u.code)
|
||||||
|
expect(codes).toContain('norn')
|
||||||
|
expect(codes).toContain('japa')
|
||||||
|
expect(codes).toContain('mythg')
|
||||||
|
expect(codes).toContain('ger')
|
||||||
|
expect(codes).toContain('arb')
|
||||||
|
expect(codes).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every usage has a non-empty label', () => {
|
||||||
|
for (const u of NAMEGEN_USAGES) {
|
||||||
|
expect(u.label.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
40
gerbil-manager-web/src/api/names.ts
Normal file
40
gerbil-manager-web/src/api/names.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface NameSuggestion {
|
||||||
|
name: string
|
||||||
|
meaning: string
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAMEGEN_USAGES = [
|
||||||
|
{ code: 'norn', label: 'Nordisch' },
|
||||||
|
{ code: 'japa', label: 'Japanisch' },
|
||||||
|
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||||
|
{ code: 'ger', label: 'Deutsch' },
|
||||||
|
{ code: 'arb', label: 'Arabisch' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||||
|
|
||||||
|
export interface SuggestNamesParams {
|
||||||
|
letter?: string
|
||||||
|
gender?: string
|
||||||
|
usages: NamegenUsageCode[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for unit tests — builds the query path without a network call. */
|
||||||
|
export function buildSuggestPath(params: SuggestNamesParams): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const letter = params.letter?.trim().toUpperCase()
|
||||||
|
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
|
||||||
|
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
|
||||||
|
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
|
||||||
|
parts.push(`count=${params.count ?? 6}`)
|
||||||
|
return `/names/suggest?${parts.join('&')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
|
||||||
|
return api.get<NameSuggestion[]>(buildSuggestPath(params))
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ export interface Gerbil {
|
|||||||
* as resident (backend default true).
|
* as resident (backend default true).
|
||||||
*/
|
*/
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -76,6 +78,7 @@ export interface CreateGerbil {
|
|||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
const nameless = de.pages.gerbils.nameless
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import { andFilter, condition } from '../api/gridify'
|
import { andFilter, condition } from '../api/gridify'
|
||||||
import type { Gender, Gerbil, Paged } from '../api/types'
|
import type { Gender, Gerbil, Paged } from '../api/types'
|
||||||
@@ -62,7 +64,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{g.name}
|
{g.name || nameless}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ interface NavItem {
|
|||||||
// Primary items are always visible in the phone bottom bar; secondary items
|
// 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.
|
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
|
||||||
const PRIMARY: NavItem[] = [
|
const PRIMARY: NavItem[] = [
|
||||||
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
|
|
||||||
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
||||||
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
||||||
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
||||||
]
|
]
|
||||||
const SECONDARY: NavItem[] = [
|
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: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||||
// INBOX-1: Anfragen-Posteingang
|
// INBOX-1: Anfragen-Posteingang
|
||||||
|
|||||||
@@ -43,16 +43,29 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="farbschlag-cards">
|
<ul className="farbschlag-cards">
|
||||||
{result.byFarbschlag.map((f) => (
|
{result.byFarbschlag.map((f) => {
|
||||||
<li key={f.farbschlag} className="farbschlag-card">
|
// Representative genotype: highest-prob offspring for this farbschlag.
|
||||||
<FarbschlagImage name={f.farbschlag} />
|
const repGenotype = result.offspring.find(
|
||||||
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
(o) => o.farbschlag === f.farbschlag,
|
||||||
<span className="farbschlag-card__prob">
|
)?.genotype
|
||||||
{f.probability.percent}
|
return (
|
||||||
<small> ({f.probability.text})</small>
|
<li key={f.farbschlag} className="farbschlag-card">
|
||||||
</span>
|
<div className="farbschlag-card__img">
|
||||||
</li>
|
<FarbschlagImage name={f.farbschlag} />
|
||||||
))}
|
</div>
|
||||||
|
<div className="farbschlag-card__body">
|
||||||
|
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
||||||
|
{repGenotype && (
|
||||||
|
<code className="farbschlag-card__geno">{repGenotype}</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="farbschlag-card__prob">
|
||||||
|
{f.probability.percent}
|
||||||
|
<small> ({f.probability.text})</small>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
.namegen-name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-name-row .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter-input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__usages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent, #2563eb);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__meaning {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__origin {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { ApiError, errorCode } from '../api/client'
|
||||||
|
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
|
||||||
|
import './NameSuggestPanel.css'
|
||||||
|
|
||||||
|
interface NameSuggestPanelProps {
|
||||||
|
gender: string
|
||||||
|
onPick: (name: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||||
|
|
||||||
|
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||||
|
const t = de.namegen
|
||||||
|
const [letter, setLetter] = useState('')
|
||||||
|
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [keyMissing, setKeyMissing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
|
||||||
|
const [fetched, setFetched] = useState(false)
|
||||||
|
|
||||||
|
function toggleUsage(code: NamegenUsageCode) {
|
||||||
|
setUsages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(code)) next.delete(code)
|
||||||
|
else next.add(code)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setKeyMissing(false)
|
||||||
|
setSuggestions([])
|
||||||
|
try {
|
||||||
|
const result = await suggestNames({
|
||||||
|
letter: letter.trim() || undefined,
|
||||||
|
gender: gender || undefined,
|
||||||
|
usages: [...usages] as NamegenUsageCode[],
|
||||||
|
count: 6,
|
||||||
|
})
|
||||||
|
setSuggestions(result)
|
||||||
|
setFetched(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (errorCode(err) === 'NamesKeyMissing') {
|
||||||
|
setKeyMissing(true)
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
|
||||||
|
<div className="namegen-panel__header">
|
||||||
|
<span>{t.panelTitle}</span>
|
||||||
|
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__filters">
|
||||||
|
<div className="namegen-panel__letter">
|
||||||
|
<label htmlFor="namegen-letter">{t.letterLabel}</label>
|
||||||
|
<input
|
||||||
|
id="namegen-letter"
|
||||||
|
className="input namegen-panel__letter-input"
|
||||||
|
value={letter}
|
||||||
|
onChange={(e) => setLetter(e.target.value)}
|
||||||
|
placeholder={t.letterPlaceholder}
|
||||||
|
maxLength={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__usages">
|
||||||
|
<span>{t.usagesLabel}</span>
|
||||||
|
<div className="namegen-usages-grid">
|
||||||
|
{NAMEGEN_USAGES.map(({ code, label }) => (
|
||||||
|
<label key={code}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={usages.has(code)}
|
||||||
|
onChange={() => toggleUsage(code)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading || usages.size === 0}
|
||||||
|
>
|
||||||
|
{loading ? t.loading : t.loadButton}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{keyMissing && <p className="muted">{t.keyMissing}</p>}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li key={`${s.name}-${i}`} className="namegen-suggestion">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="namegen-suggestion__pick"
|
||||||
|
onClick={() => onPick(s.name)}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
<span className="namegen-suggestion__meaning">{s.meaning}</span>
|
||||||
|
<span className="namegen-suggestion__origin">{s.origin}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
|
||||||
|
* aus catalog.ts übereinstimmen.
|
||||||
|
*
|
||||||
|
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
|
||||||
|
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
|
||||||
|
* und die geänderten JSON-Dateien committen.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { CATALOG } from '../catalog'
|
||||||
|
|
||||||
|
const __dir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
describe('AR-5 Catalog drift-guard', () => {
|
||||||
|
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
|
||||||
|
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
|
||||||
|
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
|
||||||
|
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
|
||||||
|
expect(committed).toEqual(Array.from(CATALOG))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -179,17 +179,21 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
|
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
|
||||||
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
|
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
|
||||||
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
|
expect(CATALOG_SIZE).toBe(70)
|
||||||
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
|
|
||||||
expect(CATALOG_SIZE).toBe(66)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||||
// The first 18 are the frozen ColorVariety keys — their representative
|
// The first 18 are the frozen ColorVariety keys — their representative
|
||||||
// genotype MUST resolve back to their own name, never a later variety.
|
// genotype MUST resolve back to their own name, never a later variety.
|
||||||
|
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
|
||||||
|
// because the REW engine check (both C-alleles reduced + pp) fires first.
|
||||||
|
// PEW stays in the catalog as a user-pickable import name; its computed
|
||||||
|
// farbschlag is intentionally 'REW' per Julian's extended rule.
|
||||||
|
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
|
||||||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||||||
|
if (REW_SHADOWED.has(entry.name)) continue
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -222,8 +226,9 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||||
// Every row has a non-empty canonical genotype display string and unique name.
|
// Every row has a non-empty canonical genotype display string and unique name.
|
||||||
|
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||||
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||||
@@ -372,9 +377,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
|
||||||
|
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
|
||||||
|
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
|
||||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||||
'Fuchsschimmel',
|
'Kohlfuchsschimmel',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||||
@@ -400,9 +407,11 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
|||||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
|
it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
|
||||||
|
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
|
||||||
|
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
|
||||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||||
'Fuchsschimmel',
|
'Kohlfuchsschimmel',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -420,7 +429,8 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
|||||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
@@ -470,8 +480,8 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
|||||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||||
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||||||
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||||||
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
|
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
|
||||||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
|
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
|
||||||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
})
|
})
|
||||||
@@ -480,11 +490,12 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
|||||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||||
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||||
|
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
|
||||||
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
|
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -506,7 +517,8 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
|
|||||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||||||
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -523,3 +535,151 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
|
|||||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
|
||||||
|
it('dd entries use Dilute prefix — no more X-dd names', () => {
|
||||||
|
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||||
|
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||||
|
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||||
|
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||||
|
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||||
|
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
||||||
|
// hom cchm/cchm + pp
|
||||||
|
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
|
||||||
|
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
||||||
|
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
||||||
|
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
||||||
|
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
||||||
|
// het cchm/ch + pp (Julian: also REW)
|
||||||
|
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
|
// ch/ch + pp (Julian: also REW — subsumes PEW)
|
||||||
|
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
|
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
|
// Counterproof: full C present → NOT REW (residual pigment)
|
||||||
|
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
|
||||||
|
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Farbarten (categories) never appear as computed results', () => {
|
||||||
|
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||||
|
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||||
|
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||||
|
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||||
|
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||||
|
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||||
|
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||||
|
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||||
|
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
|
||||||
|
for (const cat of CATS) {
|
||||||
|
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
|
||||||
|
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
|
||||||
|
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
|
||||||
|
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
|
||||||
|
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
|
||||||
|
// ── Display symbols ────────────────────────────────────────────────────
|
||||||
|
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||||
|
// Fuchsschimmel: E=[ef,ef] hom
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||||
|
'AA CC DD e[f]e[f] GG PP spsp rere',
|
||||||
|
)
|
||||||
|
// C-locus het: cchm + ch
|
||||||
|
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||||
|
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
||||||
|
)
|
||||||
|
// C-locus hom cchm
|
||||||
|
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||||
|
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── E-locus display order: E > e > e[f] ─────────────────────────────
|
||||||
|
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
|
||||||
|
// Stored canonical: [ef, e] (ef dominant over e in storage).
|
||||||
|
// Display must swap to [e, ef] per breeder convention.
|
||||||
|
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||||
|
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||||||
|
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||||
|
'aa CC DD Ee GG PP spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||||
|
'aa CC DD Ee[f] GG PP spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||||
|
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||||||
|
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
||||||
|
const g = fromDisplayString(display)
|
||||||
|
expect(g.E).toEqual(['ef', 'e'])
|
||||||
|
expect(g.C).toEqual(['C', '?'])
|
||||||
|
expect(toDisplayString(g)).toBe(display)
|
||||||
|
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
||||||
|
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
||||||
|
const g = fromDisplayString(display)
|
||||||
|
expect(g.C).toEqual(['cchm', 'ch'])
|
||||||
|
expect(g.E).toEqual(['E', 'e'])
|
||||||
|
expect(toDisplayString(g)).toBe(display)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
||||||
|
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
||||||
|
const g = fromDisplayString(display)
|
||||||
|
expect(g.C).toEqual(['C', 'ch'])
|
||||||
|
expect(g.D).toEqual(['d', 'd'])
|
||||||
|
expect(toDisplayString(g)).toBe(display)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Parser accepts both forms ─────────────────────────────────────────
|
||||||
|
it('bracket input round-trips identically to internal-symbol input', () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
|
||||||
|
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
|
||||||
|
)
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
|
||||||
|
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
|
||||||
|
)
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
|
||||||
|
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
||||||
|
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||||
|
expect(g.E).toEqual(['e', '?'])
|
||||||
|
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||||||
|
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
|
||||||
|
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
|
||||||
|
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
||||||
|
const g = fromDisplayString(input)
|
||||||
|
expect(g.E).toEqual(['e', '?'])
|
||||||
|
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||||
|
expect(g.D).toEqual(['D', 'd'])
|
||||||
|
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||||||
|
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
||||||
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
||||||
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
||||||
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
||||||
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
{ name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
||||||
|
|
||||||
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
|
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
|
||||||
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
|
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
|
||||||
@@ -78,16 +78,24 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||||
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
|
||||||
|
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||||
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
||||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||||
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||||
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||||
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||||
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
{ name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||||
|
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
|
||||||
|
// agouti+dilute+fox combinations not otherwise covered in the catalog.
|
||||||
|
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||||
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
||||||
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
||||||
|
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
|
||||||
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
||||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||||
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
||||||
@@ -104,8 +112,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
||||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||||
|
|
||||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||||
@@ -147,10 +155,12 @@ export interface FarbschlagMatch {
|
|||||||
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
||||||
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
||||||
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
||||||
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
|
* a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
|
||||||
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
|
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
|
||||||
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
|
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
|
||||||
* raw pair, which runs before this.)
|
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
|
||||||
|
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
|
||||||
|
* variety specifically.
|
||||||
*/
|
*/
|
||||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||||
@@ -162,7 +172,9 @@ function locusToken(g: Genotype, locus: LocusKey): string {
|
|||||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||||
if (locus === 'E') {
|
if (locus === 'E') {
|
||||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
|
||||||
|
// enables catalog entries like Kohlfuchsschimmel to match het animals).
|
||||||
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
|
||||||
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||||
}
|
}
|
||||||
return dominantAllele(locus, x, y)
|
return dominantAllele(locus, x, y)
|
||||||
@@ -175,10 +187,12 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
|
* E-locus family: used to scope the catalog search to E-aware entries.
|
||||||
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
|
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
|
||||||
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
|
* implies a non-dominant extension pair, or null for full-extension/unknown.
|
||||||
* Returns null when E is dominant (full colour) or fully unknown.
|
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
|
||||||
|
* They are ONLY used here as catalog-search filters; they must NEVER appear as
|
||||||
|
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||||
*/
|
*/
|
||||||
function eFamily(g: Genotype): string | null {
|
function eFamily(g: Genotype): string | null {
|
||||||
const [x, y] = g.E
|
const [x, y] = g.E
|
||||||
@@ -205,7 +219,10 @@ function baseColourFor(g: Genotype): string | null {
|
|||||||
const base = family
|
const base = family
|
||||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||||
return base?.name ?? family
|
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||||
|
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
||||||
|
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
|
||||||
|
return base?.name ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,7 +250,13 @@ function colourpointName(g: Genotype): string | null {
|
|||||||
}
|
}
|
||||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
if (!base) return null
|
||||||
|
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
||||||
|
const DILUTE = 'Dilute '
|
||||||
|
if (base.startsWith(DILUTE)) {
|
||||||
|
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
||||||
|
}
|
||||||
|
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||||
@@ -241,8 +264,30 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
|||||||
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||||
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||||
|
|
||||||
|
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
|
||||||
|
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
||||||
|
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
||||||
|
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
||||||
|
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
|
||||||
|
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
||||||
|
const [c0, c1] = resolvedPair(g, 'C')
|
||||||
|
const [p0, p1] = resolvedPair(g, 'P')
|
||||||
|
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
|
||||||
|
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
|
||||||
|
const name = ['REW', ...modifiers].join(' ')
|
||||||
|
return { name, base: null, unknown: false }
|
||||||
|
}
|
||||||
|
|
||||||
const baseName = colourpointName(g) ?? baseColourFor(g)
|
const baseName = colourpointName(g) ?? baseColourFor(g)
|
||||||
if (!baseName) {
|
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
|
||||||
|
// a computed Farbschlag output. If baseName is a category label, treat as
|
||||||
|
// Unbekannt instead of leaking an invalid name into the UI.
|
||||||
|
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
|
||||||
|
'Standard', 'Colourpoint', 'Dilute',
|
||||||
|
'Fuchs', 'Fuchsschimmel', 'Schimmel',
|
||||||
|
'Colourpoint Dilute',
|
||||||
|
])
|
||||||
|
if (!baseName || CATEGORY_NAMES.has(baseName)) {
|
||||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||||
}
|
}
|
||||||
const name = [baseName, ...modifiers].join(' ')
|
const name = [baseName, ...modifiers].join(' ')
|
||||||
|
|||||||
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Pink Eyed White (PEW)",
|
||||||
|
"english": "Pink Eyed White",
|
||||||
|
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hermelin",
|
||||||
|
"english": "Dark Tailed White",
|
||||||
|
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 1,
|
||||||
|
"image": "hermelin.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Himalaya",
|
||||||
|
"english": "Himalayan",
|
||||||
|
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 2,
|
||||||
|
"image": "himalaya.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zobel",
|
||||||
|
"english": "Sable",
|
||||||
|
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 3,
|
||||||
|
"image": "zobel.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotaugenschimmel",
|
||||||
|
"english": "Red-Eyed Roan",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 4,
|
||||||
|
"image": "rotaugen-schimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Agouti",
|
||||||
|
"english": "Golden Agouti",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 5,
|
||||||
|
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Schwarz",
|
||||||
|
"english": "Black",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 6,
|
||||||
|
"image": "schwarz.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silberagouti",
|
||||||
|
"english": "Grey Agouti",
|
||||||
|
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 7,
|
||||||
|
"image": "silberagouti.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anthrazit",
|
||||||
|
"english": "Slate",
|
||||||
|
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 8,
|
||||||
|
"image": "anthrazit.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchs",
|
||||||
|
"english": "Dark-Eyed Honey",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 9,
|
||||||
|
"image": "algierfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blau",
|
||||||
|
"english": "Blue",
|
||||||
|
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||||
|
"sortOrder": 10,
|
||||||
|
"image": "blau-schwarz-dd.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold",
|
||||||
|
"english": "Argente Golden",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 11,
|
||||||
|
"image": "gold.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Platin",
|
||||||
|
"english": "Lilac",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 12,
|
||||||
|
"image": "platin.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchs",
|
||||||
|
"english": "Yellow Fox",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 13,
|
||||||
|
"image": "goldfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchs",
|
||||||
|
"english": "Argente Nutmeg",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 14,
|
||||||
|
"image": "rotfuchs.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Gold",
|
||||||
|
"english": "dd Argente Golden",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 15,
|
||||||
|
"image": "gold-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Platin",
|
||||||
|
"english": "dd Lilac",
|
||||||
|
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 16,
|
||||||
|
"image": "platin-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Altweiss (REW)",
|
||||||
|
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||||
|
"sortOrder": 17,
|
||||||
|
"image": "altweiss-rew.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Apricot (Blassfuchs)",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||||
|
"sortOrder": 18,
|
||||||
|
"image": "apricot-blassfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchs",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 19,
|
||||||
|
"image": "blaufuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "C-Separator",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||||
|
"sortOrder": 20,
|
||||||
|
"image": "c-separator.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Elfenbein",
|
||||||
|
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||||
|
"sortOrder": 21,
|
||||||
|
"image": "elfenbein.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 22,
|
||||||
|
"image": "kohlfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 23,
|
||||||
|
"image": "polarfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Saphir",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 24,
|
||||||
|
"image": "saphir.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 25,
|
||||||
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Topas",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 26,
|
||||||
|
"image": "topas.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Platin-Hell",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 27,
|
||||||
|
"image": "platin-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Agouti",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
|
"sortOrder": 28,
|
||||||
|
"image": "agouti-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Silberagouti",
|
||||||
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
|
"sortOrder": 29,
|
||||||
|
"image": "silberagouti-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Kohlfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 30,
|
||||||
|
"image": "kohlfuchs-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Anthrazit",
|
||||||
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
|
"sortOrder": 31,
|
||||||
|
"image": "anthrazit-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Goldfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Rotfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silberschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 36,
|
||||||
|
"image": "silberschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 37,
|
||||||
|
"image": "polarfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 38,
|
||||||
|
"image": "algierfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 39,
|
||||||
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 40,
|
||||||
|
"image": "blaufuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 41,
|
||||||
|
"image": "kohlfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 42,
|
||||||
|
"image": "goldfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 43,
|
||||||
|
"image": "goldfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold-Hell",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 44,
|
||||||
|
"image": "gold-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 45,
|
||||||
|
"image": "blaufuchs-hell.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 46,
|
||||||
|
"image": "rotfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 47,
|
||||||
|
"image": "polarfuchs-hell.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 48,
|
||||||
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 49,
|
||||||
|
"image": "rotfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs-Hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 50,
|
||||||
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 51,
|
||||||
|
"image": "algierfuchs-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Topas",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 52,
|
||||||
|
"image": "topas-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Blaufuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
|
"sortOrder": 53,
|
||||||
|
"image": "blaufuchs-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marder",
|
||||||
|
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 54,
|
||||||
|
"image": "marder.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Siam",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 55,
|
||||||
|
"image": "siam-marder-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zobel-Hell",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 56,
|
||||||
|
"image": "zobel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 57,
|
||||||
|
"image": "agouti-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 59,
|
||||||
|
"image": "silberagouti-cp.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 61,
|
||||||
|
"image": "algierfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 63,
|
||||||
|
"image": "polarfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Blaufuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 69
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -2,35 +2,35 @@
|
|||||||
{
|
{
|
||||||
"name": "Pink Eyed White (PEW)",
|
"name": "Pink Eyed White (PEW)",
|
||||||
"english": "Pink Eyed White",
|
"english": "Pink Eyed White",
|
||||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hermelin",
|
"name": "Hermelin",
|
||||||
"english": "Dark Tailed White",
|
"english": "Dark Tailed White",
|
||||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 1,
|
"sortOrder": 1,
|
||||||
"image": "hermelin.jpeg"
|
"image": "hermelin.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Himalaya",
|
"name": "Himalaya",
|
||||||
"english": "Himalayan",
|
"english": "Himalayan",
|
||||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 2,
|
"sortOrder": 2,
|
||||||
"image": "himalaya.jpg"
|
"image": "himalaya.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel",
|
"name": "Zobel",
|
||||||
"english": "Sable",
|
"english": "Sable",
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 3,
|
"sortOrder": 3,
|
||||||
"image": "zobel.jpeg"
|
"image": "zobel.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotaugenschimmel",
|
"name": "Rotaugenschimmel",
|
||||||
"english": "Red-Eyed Roan",
|
"english": "Red-Eyed Roan",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 4,
|
"sortOrder": 4,
|
||||||
"image": "rotaugen-schimmel.jpg"
|
"image": "rotaugen-schimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -105,14 +105,14 @@
|
|||||||
"image": "rotfuchs.JPG"
|
"image": "rotfuchs.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dd Gold",
|
"name": "Dilute Gold",
|
||||||
"english": "dd Argente Golden",
|
"english": "dd Argente Golden",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 15,
|
"sortOrder": 15,
|
||||||
"image": "gold-dd.jpg"
|
"image": "gold-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dd Platin",
|
"name": "Dilute Platin",
|
||||||
"english": "dd Lilac",
|
"english": "dd Lilac",
|
||||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 16,
|
"sortOrder": 16,
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Orangeschimmel",
|
"name": "Orangeschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 25,
|
"sortOrder": 25,
|
||||||
"image": "schimmel-orangeschimmel.jpg"
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -185,222 +185,242 @@
|
|||||||
"image": "platin-hell.jpg"
|
"image": "platin-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agouti dd",
|
"name": "Dilute Agouti",
|
||||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
"sortOrder": 28,
|
"sortOrder": 28,
|
||||||
"image": "agouti-dd.jpg"
|
"image": "agouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberagouti dd",
|
"name": "Dilute Silberagouti",
|
||||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 29,
|
"sortOrder": 29,
|
||||||
"image": "silberagouti-dd.jpg"
|
"image": "silberagouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs dd",
|
"name": "Dilute Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
"sortOrder": 30,
|
"sortOrder": 30,
|
||||||
"image": "kohlfuchs-dd.jpg"
|
"image": "kohlfuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Anthrazit dd",
|
"name": "Dilute Anthrazit",
|
||||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 31,
|
"sortOrder": 31,
|
||||||
"image": "anthrazit-dd.jpg"
|
"image": "anthrazit-dd.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Goldfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Rotfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 35
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"name": "Silberschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 32,
|
"sortOrder": 36,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 33,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 34,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 35,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 36,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs, hell",
|
"name": "Kohlfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 37,
|
"sortOrder": 41,
|
||||||
"image": "kohlfuchs-hell.jpg"
|
"image": "kohlfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs, hell",
|
"name": "Goldfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 38,
|
"sortOrder": 42,
|
||||||
"image": "goldfuchs-hell.jpg"
|
"image": "goldfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 39,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold-Hell",
|
"name": "Gold-Hell",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 40,
|
"sortOrder": 44,
|
||||||
"image": "gold-hell.jpg"
|
"image": "gold-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs, hell",
|
"name": "Blaufuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 41,
|
"sortOrder": 45,
|
||||||
"image": "blaufuchs-hell.jpeg"
|
"image": "blaufuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 42,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs, hell",
|
"name": "Polarfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 43,
|
"sortOrder": 47,
|
||||||
"image": "polarfuchs-hell.jpeg"
|
"image": "polarfuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 44,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs, hell",
|
"name": "Rotfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 45,
|
"sortOrder": 49,
|
||||||
"image": "rotfuchs-hell.jpg"
|
"image": "rotfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs-Hell",
|
"name": "Kohlfuchs-Hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 46,
|
"sortOrder": 50,
|
||||||
"image": "kohlfuchs-hell-2.jpg"
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs, hell",
|
"name": "Algierfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 47,
|
"sortOrder": 51,
|
||||||
"image": "algierfuchs-hell.JPG"
|
"image": "algierfuchs-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas dd",
|
"name": "Dilute Topas",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 48,
|
"sortOrder": 52,
|
||||||
"image": "topas-dd.jpg"
|
"image": "topas-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs dd",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
"sortOrder": 49,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marder",
|
"name": "Marder",
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 50,
|
"sortOrder": 54,
|
||||||
"image": "marder.JPG"
|
"image": "marder.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Siam",
|
"name": "Siam",
|
||||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 51,
|
"sortOrder": 55,
|
||||||
"image": "siam-marder-hell.JPG"
|
"image": "siam-marder-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel-Hell",
|
"name": "Zobel-Hell",
|
||||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 52,
|
"sortOrder": 56,
|
||||||
"image": "zobel-hell.jpg"
|
"image": "zobel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti",
|
"name": "CP-Agouti",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 53,
|
"sortOrder": 57,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti-Hell",
|
"name": "CP-Agouti-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 54
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 55,
|
"sortOrder": 59,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti-Hell",
|
"name": "CP-Silberagouti-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 56
|
"sortOrder": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||||
"sortOrder": 57,
|
"sortOrder": 61,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs-Hell",
|
"name": "CP-Algierfuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||||
"sortOrder": 58
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||||
"sortOrder": 59,
|
"sortOrder": 63,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs-Hell",
|
"name": "CP-Polarfuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||||
"sortOrder": 60
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Fuchs",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
|
||||||
"sortOrder": 61
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Fuchs-Hell",
|
|
||||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
|
||||||
"sortOrder": 62
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Blaufuchs",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
|
||||||
"sortOrder": 63
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Orangeschimmel",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
|
||||||
"sortOrder": 64
|
"sortOrder": 64
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel-Hell",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||||
"sortOrder": 65
|
"sortOrder": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Blaufuchs",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
||||||
|
"sortOrder": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||||
|
"sortOrder": 69
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ export function wildType(): Genotype {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN-3h: breeder bracket-notation display symbols.
|
||||||
|
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
|
||||||
|
*/
|
||||||
|
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
|
||||||
|
ef: 'e[f]',
|
||||||
|
cchm: 'c[chm]',
|
||||||
|
ch: 'c[h]',
|
||||||
|
}
|
||||||
|
function displaySymbol(allele: string): string {
|
||||||
|
return DISPLAY_SYMBOL[allele] ?? allele
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
|
||||||
|
* Storage/dominance order is E > ef > e; display swaps ef and e so that
|
||||||
|
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
|
||||||
|
*/
|
||||||
|
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
|
||||||
|
|
||||||
|
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
||||||
|
if (locus !== 'E') return pair
|
||||||
|
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
|
||||||
|
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||||
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
|
|||||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||||
* convention) — e.g. ['C','?'] renders "C-".
|
* convention) — e.g. ['C','?'] renders "C-".
|
||||||
|
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
||||||
|
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
||||||
*/
|
*/
|
||||||
export function toDisplayString(g: Genotype): string {
|
export function toDisplayString(g: Genotype): string {
|
||||||
return LOCUS_ORDER.filter(
|
return LOCUS_ORDER.filter(
|
||||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
)
|
)
|
||||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
.map((locus) => {
|
||||||
|
const [a, b] = displayPair(locus, g[locus])
|
||||||
|
return displaySymbol(a) + displaySymbol(b)
|
||||||
|
})
|
||||||
|
.map((s) => s.replace(/\?/g, '-'))
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +178,19 @@ function normalizeToken(tok: string): string | null {
|
|||||||
if (t === 'WP') t = 'Slsl'
|
if (t === 'WP') t = 'Slsl'
|
||||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
||||||
|
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||||
|
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||||
|
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
||||||
|
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
||||||
|
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
|
||||||
|
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
|
||||||
|
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
|
||||||
|
// leaving the leading allele intact for splitToken).
|
||||||
|
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||||
|
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
|
||||||
|
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
|
||||||
t = t.replace(/-/g, '?')
|
t = t.replace(/-/g, '?')
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,28 +539,57 @@ textarea {
|
|||||||
margin: 0.5rem 0 1rem;
|
margin: 0.5rem 0 1rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
/* GEN-4: wider min-width prevents long names (CP-Silberagouti-Hell) from overflowing */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GEN-4 layout fix: image left, body centre (name + genotype stacked), prob right.
|
||||||
|
align-items: stretch so all three columns fill the card height consistently. */
|
||||||
.farbschlag-card {
|
.farbschlag-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
overflow: hidden; /* contain long genotype strings */
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbschlag-card__img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body takes the middle space and allows wrapping for long names */
|
||||||
|
.farbschlag-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* allow flex child to shrink below content size */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.farbschlag-card__name {
|
.farbschlag-card__name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Genotype code shown below the name — muted, small, wrappable */
|
||||||
|
.farbschlag-card__geno {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.farbschlag-card__prob {
|
.farbschlag-card__prob {
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
|
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function BeckenDetailPage() {
|
|||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
const result = await removal.run()
|
const result = await removal.run()
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
navigate('/becken')
|
navigate('/gehege')
|
||||||
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
||||||
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
||||||
setDeleteError(t.delete.conflict)
|
setDeleteError(t.delete.conflict)
|
||||||
@@ -58,7 +58,7 @@ export default function BeckenDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
||||||
<Link to="/becken" className="btn">
|
<Link to="/gehege" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
@@ -81,7 +81,7 @@ export default function BeckenDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
<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}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +92,7 @@ export default function BeckenDetailPage() {
|
|||||||
>
|
>
|
||||||
{t.delete.action}
|
{t.delete.action}
|
||||||
</button>
|
</button>
|
||||||
<Link to="/becken" className="btn">
|
<Link to="/gehege" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function BeckenFormPage() {
|
|||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
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.
|
// 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}>
|
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
||||||
{mutation.pending ? t.form.saving : t.form.save}
|
{mutation.pending ? t.form.saving : t.form.save}
|
||||||
</button>
|
</button>
|
||||||
<Link to={isEdit && id ? `/becken/${id}` : '/becken'} className="btn">
|
<Link to={isEdit && id ? `/gehege/${id}` : '/gehege'} className="btn">
|
||||||
{t.form.cancel}
|
{t.form.cancel}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function BeckenPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to="/becken/neu" className="btn btn--primary">
|
<Link to="/gehege/neu" className="btn btn--primary">
|
||||||
+ {t.newButton}
|
+ {t.newButton}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
@@ -72,7 +72,7 @@ export default function BeckenPage() {
|
|||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{items.map((e) => (
|
{items.map((e) => (
|
||||||
<li key={e.id}>
|
<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__name">{e.name}</span>
|
||||||
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
|
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { getGerbil, updateGerbil } from '../api/gerbils'
|
import { getGerbil, updateGerbil } from '../api/gerbils'
|
||||||
|
import { listLitters as listLittersPaged } from '../api/litters'
|
||||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
|
|||||||
const enclosures = useApi(() => listEnclosures(), [])
|
const enclosures = useApi(() => listEnclosures(), [])
|
||||||
const contacts = useApi(() => listContacts(), [])
|
const contacts = useApi(() => listContacts(), [])
|
||||||
const litters = useApi(() => listLitters(), [])
|
const litters = useApi(() => listLitters(), [])
|
||||||
|
const parentLitters = useApi(
|
||||||
|
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
const colorName = useMemo(
|
const colorName = useMemo(
|
||||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||||
@@ -102,7 +107,7 @@ export default function GerbilDetailPage() {
|
|||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
<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>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||||
@@ -153,7 +158,7 @@ export default function GerbilDetailPage() {
|
|||||||
{g.status === 'Deceased' && (
|
{g.status === 'Deceased' && (
|
||||||
<>
|
<>
|
||||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
<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' && (
|
{g.status === 'GivenAway' && (
|
||||||
@@ -173,10 +178,24 @@ export default function GerbilDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
||||||
<Row label={t.fields.litter} value={lookup(litterName, g.litterId)} />
|
<Row
|
||||||
<Row label={t.fields.origin} value={lookup(contactName, g.originContactId)} />
|
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.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||||
<Row label={t.fields.notes} value={g.notes} />
|
<Row label={t.fields.notes} value={g.notes || null} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h3>{t.detail.genetics}</h3>
|
<h3>{t.detail.genetics}</h3>
|
||||||
@@ -223,6 +242,32 @@ export default function GerbilDetailPage() {
|
|||||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3>{t.detail.parentLittersTitle}</h3>
|
||||||
|
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||||
|
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||||
|
)}
|
||||||
|
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||||
|
<li key={litter.id}>
|
||||||
|
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||||
|
<span className="gerbil-card__name">{litter.name}</span>
|
||||||
|
<span className="badge badge--active">
|
||||||
|
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||||
|
</span>
|
||||||
|
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||||
|
{litter.totalBorn !== null && (
|
||||||
|
<span className="gerbil-card__meta">
|
||||||
|
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
|
|||||||
import { genderLabel, statusLabel } from '../format/labels'
|
import { genderLabel, statusLabel } from '../format/labels'
|
||||||
import { fromDisplayString } from '../genetics'
|
import { fromDisplayString } from '../genetics'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
|
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||||
|
import '../components/NameSuggestPanel.css'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
@@ -21,10 +23,12 @@ interface FormState {
|
|||||||
enclosureId: string
|
enclosureId: string
|
||||||
litterId: string
|
litterId: string
|
||||||
originContactId: string
|
originContactId: string
|
||||||
|
originBreeder: string
|
||||||
receiverContactId: string
|
receiverContactId: string
|
||||||
genotype: string
|
genotype: string
|
||||||
notes: string
|
notes: string
|
||||||
isResident: boolean
|
isResident: boolean
|
||||||
|
isDeaf: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -39,10 +43,12 @@ const EMPTY: FormState = {
|
|||||||
enclosureId: '',
|
enclosureId: '',
|
||||||
litterId: '',
|
litterId: '',
|
||||||
originContactId: '',
|
originContactId: '',
|
||||||
|
originBreeder: '',
|
||||||
receiverContactId: '',
|
receiverContactId: '',
|
||||||
genotype: '',
|
genotype: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isResident: true,
|
isResident: true,
|
||||||
|
isDeaf: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -57,10 +63,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: string | null
|
enclosureId: string | null
|
||||||
litterId: string | null
|
litterId: string | null
|
||||||
originContactId: string | null
|
originContactId: string | null
|
||||||
|
originBreeder?: string | null
|
||||||
receiverContactId: string | null
|
receiverContactId: string | null
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
isResident?: boolean | null
|
isResident?: boolean | null
|
||||||
|
isDeaf?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
@@ -74,10 +82,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: g.enclosureId ?? '',
|
enclosureId: g.enclosureId ?? '',
|
||||||
litterId: g.litterId ?? '',
|
litterId: g.litterId ?? '',
|
||||||
originContactId: g.originContactId ?? '',
|
originContactId: g.originContactId ?? '',
|
||||||
|
originBreeder: g.originBreeder ?? '',
|
||||||
receiverContactId: g.receiverContactId ?? '',
|
receiverContactId: g.receiverContactId ?? '',
|
||||||
genotype: g.genotype ?? '',
|
genotype: g.genotype ?? '',
|
||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
isResident: g.isResident ?? true,
|
isResident: g.isResident ?? true,
|
||||||
|
isDeaf: g.isDeaf ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +113,7 @@ export default function GerbilFormPage() {
|
|||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [form, setForm] = useState<FormState>(EMPTY)
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||||
|
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||||
|
|
||||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||||
@@ -167,10 +178,12 @@ export default function GerbilFormPage() {
|
|||||||
enclosureId: nn(form.enclosureId),
|
enclosureId: nn(form.enclosureId),
|
||||||
litterId: nn(form.litterId),
|
litterId: nn(form.litterId),
|
||||||
originContactId: nn(form.originContactId),
|
originContactId: nn(form.originContactId),
|
||||||
|
originBreeder: nn(form.originBreeder),
|
||||||
receiverContactId: nn(form.receiverContactId),
|
receiverContactId: nn(form.receiverContactId),
|
||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
isResident: form.isResident,
|
isResident: form.isResident,
|
||||||
|
isDeaf: form.isDeaf,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
@@ -187,16 +200,33 @@ export default function GerbilFormPage() {
|
|||||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||||
|
|
||||||
<form className="form" onSubmit={onSubmit} noValidate>
|
<form className="form" onSubmit={onSubmit} noValidate>
|
||||||
<label className="field">
|
<div className="field">
|
||||||
<span>{t.fields.name} *</span>
|
<label htmlFor="gerbil-name">{t.fields.name} *</label>
|
||||||
<input
|
<div className="namegen-name-row">
|
||||||
className="input"
|
<input
|
||||||
value={form.name}
|
id="gerbil-name"
|
||||||
onChange={(e) => set('name', e.target.value)}
|
className="input"
|
||||||
aria-invalid={Boolean(errors.name)}
|
value={form.name}
|
||||||
/>
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => setShowNameSuggest((v) => !v)}
|
||||||
|
>
|
||||||
|
{de.namegen.button}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
{errors.name && <small className="error-text">{errors.name}</small>}
|
||||||
</label>
|
</div>
|
||||||
|
{showNameSuggest && (
|
||||||
|
<NameSuggestPanel
|
||||||
|
gender={form.gender}
|
||||||
|
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
|
||||||
|
onClose={() => setShowNameSuggest(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.gender} *</span>
|
<span>{t.fields.gender} *</span>
|
||||||
@@ -326,6 +356,16 @@ export default function GerbilFormPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.originBreeder}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.originBreeder}
|
||||||
|
placeholder="z. B. Zucht der Kleinen Chaoten"
|
||||||
|
onChange={(e) => set('originBreeder', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.receiver}</span>
|
<span>{t.fields.receiver}</span>
|
||||||
<select
|
<select
|
||||||
@@ -360,6 +400,21 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.isDeaf}</span>
|
||||||
|
<select
|
||||||
|
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
set('isDeaf', v === '' ? null : v === 'true')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t.form.isDeafUnknown}</option>
|
||||||
|
<option value="true">{t.form.isDeafYes}</option>
|
||||||
|
<option value="false">{t.form.isDeafNo}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="field field--check" title={t.form.isResidentHint}>
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
<span>{t.form.isResidentLabel}</span>
|
<span>{t.form.isResidentLabel}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -256,11 +256,11 @@ export default function GerbilsPage() {
|
|||||||
className="gerbil-row__check"
|
className="gerbil-row__check"
|
||||||
checked={selected.has(g.id)}
|
checked={selected.has(g.id)}
|
||||||
onChange={() => toggleSelect(g.id)}
|
onChange={() => toggleSelect(g.id)}
|
||||||
aria-label={g.name}
|
aria-label={g.name || t.nameless}
|
||||||
/>
|
/>
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">
|
<span className="gerbil-card__name">
|
||||||
{g.name}
|
{g.name || t.nameless}
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={t.externalTitle}>
|
<span className="badge badge--external" title={t.externalTitle}>
|
||||||
{t.externalBadge}
|
{t.externalBadge}
|
||||||
|
|||||||
@@ -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>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>
|
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
|
||||||
<ol>
|
<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>Rennmäuse anlegen mit Foto</li>
|
||||||
<li>Würfe eintragen</li>
|
<li>Würfe eintragen</li>
|
||||||
<li>Genetik erkunden</li>
|
<li>Genetik erkunden</li>
|
||||||
</ol>
|
</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: (
|
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>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>
|
<p>Auf der Detailseite findest du drei Tabs:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>„{g.detail.tabs.photos}"</strong> — Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
|
<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,
|
title: t.sections.becken,
|
||||||
content: (
|
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>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>
|
<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>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -276,7 +276,7 @@ export default function StammbaumPage() {
|
|||||||
<>
|
<>
|
||||||
<section className="page stammbaum-screen">
|
<section className="page stammbaum-screen">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<h2>{t.titleFor(root.gerbil.name)}</h2>
|
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||||
{t.backToAnimal}
|
{t.backToAnimal}
|
||||||
@@ -377,7 +377,7 @@ function PedigreeCard({
|
|||||||
<div className="pedigree-card__body">
|
<div className="pedigree-card__body">
|
||||||
<div className="pedigree-card__name">
|
<div className="pedigree-card__name">
|
||||||
<SexIcon gender={g.gender} />
|
<SexIcon gender={g.gender} />
|
||||||
<span className="pedigree-card__nametext">{g.name}</span>
|
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
||||||
</div>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
@@ -439,7 +439,7 @@ function PrintPedigree({
|
|||||||
return (
|
return (
|
||||||
<div className="stammbaum-print">
|
<div className="stammbaum-print">
|
||||||
<header className="stammbaum-print__head">
|
<header className="stammbaum-print__head">
|
||||||
<h1>{t.titleFor(root.gerbil.name)}</h1>
|
<h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
|
||||||
<p className="stammbaum-print__meta">
|
<p className="stammbaum-print__meta">
|
||||||
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
||||||
</p>
|
</p>
|
||||||
@@ -494,7 +494,7 @@ function PrintCell({
|
|||||||
<div className={base} style={style}>
|
<div className={base} style={style}>
|
||||||
<div className="stammbaum-print__name">
|
<div className="stammbaum-print__name">
|
||||||
{sexSymbol}
|
{sexSymbol}
|
||||||
{g.name}
|
{g.name || de.pages.gerbils.nameless}
|
||||||
</div>
|
</div>
|
||||||
{g.dateOfBirth && (
|
{g.dateOfBirth && (
|
||||||
<div className="stammbaum-print__sub">
|
<div className="stammbaum-print__sub">
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
|
|||||||
checked={selectedIds.has(g.id)}
|
checked={selectedIds.has(g.id)}
|
||||||
onChange={() => toggleAnimal(g.id)}
|
onChange={() => toggleAnimal(g.id)}
|
||||||
/>
|
/>
|
||||||
<span className="wizard-pick__name">{g.name}</span>
|
<span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
|
||||||
<span className="wizard-pick__meta">
|
<span className="wizard-pick__meta">
|
||||||
{[
|
{[
|
||||||
genderLabel(g.gender),
|
genderLabel(g.gender),
|
||||||
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.animals}</dt>
|
<dt>{de.pages.vertraege.fields.animals}</dt>
|
||||||
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
|
<dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.price}</dt>
|
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ export default function WurfFormPage() {
|
|||||||
name: l.name,
|
name: l.name,
|
||||||
date: l.date ?? '',
|
date: l.date ?? '',
|
||||||
fatherId: l.fatherId ?? '',
|
fatherId: l.fatherId ?? '',
|
||||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
|
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
|
||||||
motherId: l.motherId ?? '',
|
motherId: l.motherId ?? '',
|
||||||
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
|
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
|
||||||
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||||
notes: l.notes ?? '',
|
notes: l.notes ?? '',
|
||||||
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="male"
|
gender="male"
|
||||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
|
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="female"
|
gender="female"
|
||||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
|
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ export const de = {
|
|||||||
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
|
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Start',
|
|
||||||
gerbils: 'Rennmäuse',
|
gerbils: 'Rennmäuse',
|
||||||
litters: 'Würfe',
|
litters: 'Würfe',
|
||||||
breeders: 'Züchter',
|
breeders: 'Züchter',
|
||||||
genetics: 'Genetik',
|
genetics: 'Genetik',
|
||||||
// FEAT-2 (Oscar): Becken + Kontakte
|
// FEAT-2 (Oscar): Becken + Kontakte (GEHEGE-RENAME: Becken → Gehege)
|
||||||
enclosures: 'Becken',
|
enclosures: 'Gehege',
|
||||||
contacts: 'Kontakte',
|
contacts: 'Kontakte',
|
||||||
// FEAT-7 (Kelly): Statistik
|
// FEAT-7 (Kelly): Statistik
|
||||||
statistics: 'Statistik',
|
statistics: 'Statistik',
|
||||||
@@ -37,13 +36,9 @@ export const de = {
|
|||||||
website: 'Webseite',
|
website: 'Webseite',
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
home: {
|
|
||||||
title: 'Willkommen',
|
|
||||||
intro:
|
|
||||||
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
|
|
||||||
},
|
|
||||||
gerbils: {
|
gerbils: {
|
||||||
title: 'Rennmäuse',
|
title: 'Rennmäuse',
|
||||||
|
nameless: '(ohne Namen)',
|
||||||
// Liste
|
// Liste
|
||||||
newButton: 'Neue Rennmaus',
|
newButton: 'Neue Rennmaus',
|
||||||
empty: 'Keine Rennmäuse gefunden.',
|
empty: 'Keine Rennmäuse gefunden.',
|
||||||
@@ -82,9 +77,11 @@ export const de = {
|
|||||||
causeOfDeath: 'Todesursache',
|
causeOfDeath: 'Todesursache',
|
||||||
goHomeDate: 'Abgabedatum',
|
goHomeDate: 'Abgabedatum',
|
||||||
litter: 'Wurf',
|
litter: 'Wurf',
|
||||||
enclosure: 'Becken',
|
enclosure: 'Gehege',
|
||||||
colorVariety: 'Farbschlag',
|
colorVariety: 'Farbschlag',
|
||||||
origin: 'Herkunft',
|
origin: 'Herkunft',
|
||||||
|
originBreeder: 'Herkunft (Freitext)',
|
||||||
|
isDeaf: 'Gehörlos',
|
||||||
receiver: 'Abnehmer',
|
receiver: 'Abnehmer',
|
||||||
genotype: 'Genotyp',
|
genotype: 'Genotyp',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
@@ -108,6 +105,11 @@ export const de = {
|
|||||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||||
moreData: 'Weitere Daten',
|
moreData: 'Weitere Daten',
|
||||||
|
// ANIMAL-LITTERS: Würfe als Elternteil
|
||||||
|
parentLittersTitle: 'Würfe als Elternteil',
|
||||||
|
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
||||||
|
parentLittersRoleVater: 'Vater',
|
||||||
|
parentLittersRoleMutter: 'Mutter',
|
||||||
},
|
},
|
||||||
// Formular (anlegen/bearbeiten)
|
// Formular (anlegen/bearbeiten)
|
||||||
form: {
|
form: {
|
||||||
@@ -119,6 +121,9 @@ export const de = {
|
|||||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
isResidentLabel: 'Gehört zum eigenen Bestand',
|
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||||
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||||
|
isDeafUnknown: 'Unbekannt',
|
||||||
|
isDeafYes: 'Ja',
|
||||||
|
isDeafNo: 'Nein',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -253,8 +258,8 @@ export const de = {
|
|||||||
markMultiAction: 'Ausgewählte zur Abgabe stellen',
|
markMultiAction: 'Ausgewählte zur Abgabe stellen',
|
||||||
grouping: {
|
grouping: {
|
||||||
title: 'Gruppen',
|
title: 'Gruppen',
|
||||||
byEnclosure: 'Nach Becken vorgeschlagen',
|
byEnclosure: 'Nach Gehege vorgeschlagen',
|
||||||
ungrouped: 'Ohne Becken',
|
ungrouped: 'Ohne Gehege',
|
||||||
moveTo: 'Verschieben nach',
|
moveTo: 'Verschieben nach',
|
||||||
newGroup: 'Neue Gruppe',
|
newGroup: 'Neue Gruppe',
|
||||||
groupLabel: 'Gruppe',
|
groupLabel: 'Gruppe',
|
||||||
@@ -298,12 +303,12 @@ export const de = {
|
|||||||
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
|
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-2 (Oscar): Becken (Enclosures) ──
|
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
|
||||||
becken: {
|
becken: {
|
||||||
title: 'Becken',
|
title: 'Gehege',
|
||||||
newButton: 'Neues Becken',
|
newButton: 'Neues Gehege',
|
||||||
empty: 'Keine Becken gefunden.',
|
empty: 'Keine Gehege gefunden.',
|
||||||
countLabel: 'Becken',
|
countLabel: 'Gehege',
|
||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
fields: {
|
fields: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -311,18 +316,18 @@ export const de = {
|
|||||||
},
|
},
|
||||||
occupancy: {
|
occupancy: {
|
||||||
title: 'Bewohner',
|
title: 'Bewohner',
|
||||||
empty: 'Dieses Becken ist derzeit leer.',
|
empty: 'Dieses Gehege ist derzeit leer.',
|
||||||
countOne: 'Tier',
|
countOne: 'Tier',
|
||||||
countMany: 'Tiere',
|
countMany: 'Tiere',
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
back: 'Zurück zur Liste',
|
back: 'Zurück zur Liste',
|
||||||
notFound: 'Dieses Becken wurde nicht gefunden.',
|
notFound: 'Dieses Gehege wurde nicht gefunden.',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
createTitle: 'Neues Becken anlegen',
|
createTitle: 'Neues Gehege anlegen',
|
||||||
editTitle: 'Becken bearbeiten',
|
editTitle: 'Gehege bearbeiten',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -332,9 +337,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
action: 'Löschen',
|
action: 'Löschen',
|
||||||
confirmMessage: 'Soll dieses Becken wirklich gelöscht werden?',
|
confirmMessage: 'Soll dieses Gehege wirklich gelöscht werden?',
|
||||||
conflict:
|
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) ──
|
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
|
||||||
@@ -731,7 +736,7 @@ export const de = {
|
|||||||
sections: {
|
sections: {
|
||||||
ersteSchritte: 'Erste Schritte',
|
ersteSchritte: 'Erste Schritte',
|
||||||
tiere: 'Rennmäuse anlegen & Fotos',
|
tiere: 'Rennmäuse anlegen & Fotos',
|
||||||
becken: 'Becken',
|
becken: 'Gehege',
|
||||||
wuerfe: 'Würfe & Jungtiere',
|
wuerfe: 'Würfe & Jungtiere',
|
||||||
genetik: 'Probeverpaarung & Genetik',
|
genetik: 'Probeverpaarung & Genetik',
|
||||||
stammbaum: 'Stammbaum',
|
stammbaum: 'Stammbaum',
|
||||||
@@ -813,6 +818,19 @@ export const de = {
|
|||||||
resetButton: 'Filter zurücksetzen',
|
resetButton: 'Filter zurücksetzen',
|
||||||
closeButton: 'Schließen',
|
closeButton: 'Schließen',
|
||||||
},
|
},
|
||||||
|
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
|
||||||
|
namegen: {
|
||||||
|
button: 'Name vorschlagen',
|
||||||
|
panelTitle: 'Namensvorschläge',
|
||||||
|
letterLabel: 'Anfangsbuchstabe',
|
||||||
|
letterPlaceholder: 'z. B. A',
|
||||||
|
usagesLabel: 'Herkunftskultur',
|
||||||
|
loadButton: 'Vorschläge laden',
|
||||||
|
loading: 'Lade Vorschläge …',
|
||||||
|
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
|
||||||
|
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
|
||||||
|
close: 'Schließen',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
@@ -84,6 +84,34 @@
|
|||||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Little Runner's Big Ben",
|
||||||
|
"dob": "03.02.2020",
|
||||||
|
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
||||||
|
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vance Jr. von den Kleinen Chaoten",
|
||||||
|
"dob": "10.04.2022",
|
||||||
|
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
||||||
|
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skarlett von den Kleinen Chaoten",
|
||||||
|
"dob": "14.07.2013",
|
||||||
|
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
||||||
|
"dateOfDeath": "17.04.2016",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kazu von den Kleinen Chaoten",
|
||||||
|
"dob": "23.04.2013",
|
||||||
|
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||||
|
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ def norm_zucht(z):
|
|||||||
if not z:
|
if not z:
|
||||||
return ""
|
return ""
|
||||||
n = z.lower()
|
n = z.lower()
|
||||||
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
|
# TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
|
||||||
|
# (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b.
|
||||||
|
n = re.sub(r"\bv\.\s?d\.", " ", n)
|
||||||
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
||||||
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
||||||
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
||||||
@@ -137,6 +139,15 @@ def norm_zucht(z):
|
|||||||
return ZUCHT_ALIASES.get(key, key)
|
return ZUCHT_ALIASES.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_clan_zucht(z):
|
||||||
|
"""True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery.
|
||||||
|
|
||||||
|
Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten',
|
||||||
|
'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc.
|
||||||
|
"""
|
||||||
|
return norm_zucht(z) == "kleinechaote"
|
||||||
|
|
||||||
|
|
||||||
def canon_pair(raw):
|
def canon_pair(raw):
|
||||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||||
name, zucht = split_name_zucht(raw)
|
name, zucht = split_name_zucht(raw)
|
||||||
@@ -962,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
if d.get("genotype"):
|
if d.get("genotype"):
|
||||||
a["genotype"] = gt.parse(d["genotype"])
|
# CR-10: validate the parsed genotype — a typo'd decision string yields empty
|
||||||
|
# mapped8locus and would silently blank the animal's genotype while marking it
|
||||||
|
# 'resolved'. Only apply if the parse produces non-empty loci.
|
||||||
|
parsed = gt.parse(d["genotype"])
|
||||||
|
if parsed.get("mapped8locus"):
|
||||||
|
a["genotype"] = parsed
|
||||||
|
else:
|
||||||
|
# Keep the existing genotype; flag as a warning in the report.
|
||||||
|
a.setdefault("decisionWarnings", []).append(
|
||||||
|
f"Ungültiger Override-Genotyp '{d['genotype']}' — "
|
||||||
|
"konnte nicht geparst werden (mapped8locus leer). "
|
||||||
|
"Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
|
||||||
|
)
|
||||||
if d.get("farbschlag"):
|
if d.get("farbschlag"):
|
||||||
a["farbschlag"] = d["farbschlag"]
|
a["farbschlag"] = d["farbschlag"]
|
||||||
a["farbschlagVariants"] = [d["farbschlag"]]
|
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||||
|
|||||||
@@ -280,6 +280,63 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W
|
|||||||
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||||
|
|
||||||
|
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
|
||||||
|
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
|
||||||
|
_json.dump({"resolutions": [
|
||||||
|
# Valid decision (genotype parses OK) -> should be applied
|
||||||
|
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
|
||||||
|
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
|
||||||
|
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
|
||||||
|
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
|
||||||
|
"genotype": "BLÖDSINN!!!", "source": "test"},
|
||||||
|
]}, open(dec_cr10, "w", encoding="utf-8"))
|
||||||
|
merged_cr10 = [
|
||||||
|
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
|
||||||
|
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
|
||||||
|
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
|
||||||
|
]
|
||||||
|
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
|
||||||
|
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
|
||||||
|
check("CR-10: valid decision genotype is applied (A-locus updated)",
|
||||||
|
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
|
||||||
|
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
|
||||||
|
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
|
||||||
|
check("CR-10: malformed decision still un-quarantines the animal",
|
||||||
|
merged_cr10[1].get("conflict") is False)
|
||||||
|
check("CR-10: malformed decision adds a decisionWarning",
|
||||||
|
bool(merged_cr10[1].get("decisionWarnings")))
|
||||||
|
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
|
||||||
|
try: os.remove(dec_cr10)
|
||||||
|
except OSError: pass
|
||||||
|
|
||||||
|
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
||||||
|
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
||||||
|
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
||||||
|
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
|
||||||
|
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
|
||||||
|
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
|
||||||
|
e.is_clan_zucht("kleinen Chaoten"))
|
||||||
|
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
|
||||||
|
e.is_clan_zucht("v.d. Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
|
||||||
|
e.is_clan_zucht("ZdkC"))
|
||||||
|
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
|
||||||
|
e.is_clan_zucht("von den Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: empty string -> NOT clan",
|
||||||
|
not e.is_clan_zucht(""))
|
||||||
|
check("KC-matcher: 'Black Forest' -> NOT clan",
|
||||||
|
not e.is_clan_zucht("Black Forest"))
|
||||||
|
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
|
||||||
|
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
|
||||||
|
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
|
||||||
|
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
|
||||||
|
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
|
||||||
|
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
|
||||||
|
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
|
||||||
|
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print(f"\n{failed} test(s) FAILED")
|
print(f"\n{failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user