Compare commits

...

27 Commits

Author SHA1 Message Date
c0ecf2022d WEB-2: Self-hosted public site (publicsite-nginx + POST /api/publish, atomic swap)
POST /api/publish: rendert SiteSnapshot->HTML in _staging_new/, atomic swap ->
live/ (rename, ein Syscall). publicsite-nginx:alpine serviert live/ read-only
auf Port 8081. Shared Volume api(rw)/publicsite(ro). Manager bleibt LAN-only.
5 neue Tests (atomic swap, UTF-8, mehrfach), 184/184 gruen. compose config OK.
Vhost-Snippet + web-deploy.md (Deutsch) beigelegt; <DOMAIN> wartet auf Julian.
2026-06-07 01:44:28 +02:00
04971bf3bb Merge feature/stammbaum-litters (STAMMBAUM-LITTERS): Würfe des Wurzeltiers links im Viewer + Link
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:38:10 +02:00
08500757d7 STAMMBAUM-LITTERS: Wuerfe des Wurzeltiers links im Stammbaum-Viewer
Layout: stammbaum-layout (flex-row Desktop / flex-column Mobil) wraps
[Wuerfe-Panel | Canvas]. Panel 148px breit, border-right Trenner;
auf Mobil (<=520px) horizontaler Scroll-Streifen ueber dem Canvas.

Daten: useApi(listLitters fatherId=id|motherId=id) reagiert automatisch
auf Umwurzeln (id-Param). Kein Panel wenn Wurzeltier keine Wuerfe hat.

Pro Wurf: Wurfname (fett) + N Junge + Link zu /wuerfe/{id}.
CSS: flex 0 0 auto Mobil-Override sichert Canvas-Hoehe im column-Mode.
de.ts: littersTitle, littersJunge (Stammbaum-Sektion, disjunkt).

Gate: vitest 122/122 e2e 20/20 Stammbaum (4 neue Tests x 2 Viewports)
build+tsc+eslint clean.
2026-06-07 01:37:08 +02:00
05ef3d9567 Merge feature/namegen-2-fe (NAMEGEN-2-FE): 10 Namens-Kategorien UI (disney/pokemon/EN+HR-Städte/US-Staaten)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:35:40 +02:00
7c12a65938 Merge feature/namegen-2-be (NAMEGEN-2-BE): 5 Themen-Kategorien disney/pokemon/encities/hrcities/usstates für /names/suggest
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:34:52 +02:00
5361a292f1 NAMEGEN-2-BE: 5 neue Themen-Kategorien (disney/pokemon/encities/hrcities/usstates)
UsageMap bildet alle Codes auf deutsche Beschreibungen ab; thematische Kategorien
landen unter "Themen-Kategorien" im Prompt (kein Etymologie-Zwang, origin=Kategoriename).
22 neue Tests, 179/179 gruen.
2026-06-07 01:32:56 +02:00
cb5acd2005 Merge feature/genotype-display-rex (GEN-4d + UI-FIX): Rex-Wildtyp 'rere' in Anzeige ausblenden (7 Loci) + doppelte Wurf-Überschrift entfernt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:31:19 +02:00
bda5479b93 UI-FIX: doppelte Überschrift 'Erwartete Farbschläge' auf Wurf-Detailseite entfernt 2026-06-07 01:29:49 +02:00
d5c155953b GEN-4d: rere (Re-Wildtyp) aus Anzeige ausblenden (Julian)
- genotype.ts: toDisplayString filtert Re aus wenn re/re (Wildtyp) —
  analog zur bestehenden Sls-Regel; Rere/ReRe bleiben sichtbar.
  Wildtyp-Anzeige jetzt 7 Loci: AA CC DD EE GG PP spsp (ohne rere).
- StammbaumPage.tsx: Druck-Ahnentafel nutzt toDisplayString(fromDisplayString(g.genotype))
  statt rohem g.genotype-String (Audit: einzige Display-Stelle ausserhalb toDisplayString).
  (GerbilDetailPage/BreedingResultView gehen bereits via toDisplayString.)
- genetics.test.ts: Alle Display-String-Assertions auf 7-Loci-Format aktualisiert;
  Rere-Nachweis zum Sls/Re-Test ergaenzt; Katalog-Regex auf {6,8}.
- colorVarietySeed.generated.json regeneriert (kein rere in canonicalGenotype).
- backend.json unveraendert (Pam: DB-Speicherung bleibt volles 8-Loki-Format).
2026-06-07 01:28:06 +02:00
36795bd974 Merge feature/seed-rew (SEED-REW): Backend ColorVariety 'Pink Eyed White (PEW)' → 'REW' rename-in-place (FK-safe)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
2026-06-07 01:19:26 +02:00
ae60b47ad3 D7: Quelldatei(en) je offenem Konflikt-Tier angereichert (Dakota/Max + alle 6 offenen)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m1s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:15:04 +02:00
adb9e93cdb SEED-REW: rename ColorVariety 'Pink Eyed White (PEW)' to 'REW'
Single UpdateData migration for ID 00000000-...-0001 (sortOrder 0).
Name-only rename, genotype/ID/FK unchanged — no drift risk.
Matches colorVarietySeed.backend.json after GEN-4c (main 3aa9ac9).

165/165 tests, ef has-pending=No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 01:12:55 +02:00
3aa9ac9811 Merge feature/gen-4c (GEN-4c): PEW→REW konsolidiert (Julian REW-1) + REW-2 A-unabhängig verifiziert + aa-Test
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
2026-06-07 01:10:18 +02:00
b72e0a9d9a GEN-4c: PEW=REW konsolidiert (Julian) + REW-2-Verifikation
- catalog.ts: 'Pink Eyed White (PEW)' -> 'REW' (frozen rename; chch+pp
  engine-computes 'REW' per Julian; entry now round-trips correctly)
- genetics.test.ts: REW_SHADOWED-Ausnahme entfernt (kein Bedarf mehr);
  CATALOG[0]-Assertion auf 'REW'; aa cchmcchm+pp-Test als REW-2-Nachweis
  (Julian: A-Locus egal fuer REW-Bedingung)
- chipColors.ts: 'REW'-Chip ergaenzt (gleiche Farbe wie PEW; additive;
  PEW-Eintrag bleibt fuer Rueckwaertskompatibilitaet waehrend DB-Migration)
- Seed-Artefakte regeneriert (70 Reihen, REW statt PEW an Position 0)
- FLAGGED to god: 'Altweiss (REW)' hat volles C (C:'C') -> faellt NICHT
  unter REW-Bedingung; warte auf Julian-Entscheid vor Aenderung
2026-06-07 01:08:23 +02:00
12d85d9796 WEB-Hosting-Pivot: Cloudflare → Self-Host auf TrueNAS (Strato/DynDNS + nginx-Reverse-Proxy); A4 umgestellt
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:07:30 +02:00
76bdde4649 B2 Firewall erledigt — Handy-WLAN-Zugriff (Ports 5173/5179) offen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:04:00 +02:00
025bca1840 REW-1/REW-2 beantwortet: PEW=REW (zusammenführen, GEN-4c) + REW A-unabhängig (schon implementiert)
Some checks failed
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Docker Build & Push (push) Has been cancelled
CI / Backend Tests (.NET) (push) Has been cancelled
2026-06-07 01:03:05 +02:00
58dc811574 E1/E2 Charakterbogen als ERLEDIGT markiert (CHARAKTERBOGEN-2: 4 Kategorien, +10 Traits, Warnsignale) — war stale
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:59:37 +02:00
367304940f D7: Osamu/Percy/Iwana Konfliktentscheidungen (Züchterin) + Eragon=Elieus C=CC bestätigt → conflict-decisions.json + HUMANQUESTION (7/13 erledigt)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:58:04 +02:00
7e1acdb5e4 A5 beantwortet: TrueNAS Goldeye 25.10 + eigener Postgres + Backups unter /mnt/JailStorage/DockerVolumes — OPS-2 entsperrt
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 1m2s
2026-06-07 00:54:26 +02:00
5ee6a4edf6 Merge feature/stammbaum-expand (STAMMBAUM-EXPAND): prominenter +-Vorfahren-Button (40px) + Bottom-Mini-Legende (Name→Akte)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:47:05 +02:00
ea527c7ec6 CI: Registry auf externes HTTPS git.rismer.de (statt 192.168.2.115:13000 HTTP) — keine insecure-registry-Konfig nötig
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:45:39 +02:00
c80adcf060 Merge feature/fix-8d-retry (FIX-8D-RETRY P0): wrap docx import tx in CreateExecutionStrategy.ExecuteAsync (Npgsql retrying strategy) + regression
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 58s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:40:11 +02:00
886a6e3aae D7: Kazumi/Filou/Sokrates Konfliktentscheidungen (Züchterin) → conflict-decisions.json + HUMANQUESTION gestrichen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:38:05 +02:00
88e00b3718 FIX-8D-RETRY: wrap docx execute transaction in CreateExecutionStrategy
NpgsqlRetryingExecutionStrategy rejects user-initiated transactions: SaveChanges
inside a BeginTransactionAsync block triggers OnFirstExecution which throws
InvalidOperationException. Fix: CreateExecutionStrategy().ExecuteAsync wraps the
entire tx block; mutable state (counters, contactByNorm, change tracker) reset at
lambda top for idempotent retry. Logic extracted to RunLoopAsync local function
shared by dry-run and execute paths.

Regression test (Test 7): FakeRetryingStrategy with MaxRetryCount=1 reproduces
the OnFirstExecution check in CI without a live Npgsql instance.

165/165 tests, ef has-pending=No, no schema change.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-07 00:34:26 +02:00
3214855989 A5b2: konkrete Schritte für Registry-Secrets + insecure-registry-Hinweis
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 00:33:40 +02:00
c452b69dd6 NAMEGEN-2-FE: 5 neue Namenskategorien (Disney, Pokémon, Englische/Kroatische Städte, US-Bundesstaaten)
- NAMEGEN_USAGES: +disney, +pokemon, +encities, +hrcities, +usstates (jetzt 10 Einträge)
- names.test.ts: Count-Test 5→10, neue toContain-Checks für alle 5 Codes

Gate: vitest 107/107, e2e 160/160, tsc clean
2026-06-06 21:48:08 +02:00
33 changed files with 2552 additions and 215 deletions

View File

@@ -9,10 +9,10 @@
# 2. npm test + npm run build (Frontend) # 2. npm test + npm run build (Frontend)
# 3. Docker-Images bauen und in die Gitea-Registry pushen # 3. Docker-Images bauen und in die Gitea-Registry pushen
# #
# Registry: 192.168.2.115:13000 (internes Gitea Container Registry) # Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
# Images: # Images:
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest # git.rismer.de/gulum/gerbilmanager-api:latest
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest # git.rismer.de/gulum/gerbilmanager-frontend:latest
name: CI name: CI
@@ -25,7 +25,7 @@ on:
- main - main
env: env:
REGISTRY: 192.168.2.115:13000 REGISTRY: git.rismer.de
REGISTRY_OWNER: gulum REGISTRY_OWNER: gulum
DOTNET_VERSION: "10.0.x" DOTNET_VERSION: "10.0.x"
NODE_VERSION: "22" NODE_VERSION: "22"

View File

@@ -0,0 +1,103 @@
using GerbilManagerWebAPI.Endpoints;
namespace GerbilManager.Tests;
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
public class CmsPublishTests
{
private static string TempRoot() =>
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
[Fact]
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
{
var root = TempRoot();
try
{
var files = new Dictionary<string, string>
{
["index.html"] = "<html>start</html>",
["kontakt/index.html"] = "<html>kontakt</html>",
["assets/site.css"] = "body {}",
};
await CmsEndpoints.PublishToDirectoryAsync(files, root);
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
Assert.Equal("<html>start</html>",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
Assert.Equal("version-2", content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "x" }, root);
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_mehrfach_ohne_Fehler()
{
var root = TempRoot();
try
{
for (int i = 1; i <= 3; i++)
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
}
Assert.Equal("v3",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
{
var root = TempRoot();
try
{
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = german }, root);
var content = await File.ReadAllTextAsync(
Path.Combine(root, "live", "index.html"),
System.Text.Encoding.UTF8);
Assert.Equal(german, content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}

View File

@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace GerbilManager.Tests namespace GerbilManager.Tests
{ {
@@ -226,6 +227,52 @@ namespace GerbilManager.Tests
} }
} }
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
[Fact]
public async Task Execute_works_under_retrying_execution_strategy()
{
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
// OnFirstExecution() at the start of ExecuteAsync, which throws
// InvalidOperationException when it detects a user-initiated transaction
// that was NOT opened through the strategy. This test wires the same check
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
// without a live Npgsql instance.
//
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
// → OnFirstExecution sees active user tx → InvalidOperationException
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
// → OnFirstExecution: no tx yet → OK
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseSqlite(conn)
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
owner = "Retry Adopter", abgabeDate = "01.03.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.Contacts.CountAsync());
}
}
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─ // ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
[Fact] [Fact]
@@ -273,4 +320,24 @@ namespace GerbilManager.Tests
} }
} }
} }
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
/// <summary>
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
/// OnFirstExecution() throws when it detects a user-initiated transaction
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
/// </summary>
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
{
protected override bool ShouldRetryOn(Exception exception) => false;
}
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
: IExecutionStrategyFactory
{
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
}
} }

View File

@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
namespace GerbilManager.Tests namespace GerbilManager.Tests
{ {
/// <summary> /// <summary>
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown /// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
/// fence strip), 503-not-configured path, upstream-error path. /// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
/// </summary> /// </summary>
public class NameSuggestionTests public class NameSuggestionTests
{ {
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
Assert.Contains("origin", prompt); Assert.Contains("origin", prompt);
} }
[Fact]
public void SystemPrompt_erklärt_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("thematischen Kategorien", prompt);
Assert.Contains("Erfinde KEINE Etymologie", prompt);
}
[Fact] [Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben() public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{ {
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
Assert.Contains("6", prompt); Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt); Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt); Assert.Contains("weibliche", prompt);
Assert.Contains("norn,mythg", prompt); // codes are mapped to German descriptions
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Griechische Mythologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
} }
[Fact] [Fact]
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
Assert.DoesNotContain("männliche", prompt); Assert.DoesNotContain("männliche", prompt);
} }
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
{
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
Assert.Equal(expectedDescription, entry.Description);
Assert.True(entry.Thematic);
}
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
Assert.Contains(expectedDescription, prompt);
Assert.Contains("Themen-Kategorien", prompt);
Assert.DoesNotContain("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
Assert.Contains("Disney-Charaktere", prompt);
Assert.Contains("Themen-Kategorien", prompt);
}
[Fact]
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
}
[Fact]
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
{
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync("D", null, "disney", 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal("Dumbo", result.Suggestions![0].Name);
Assert.Equal("Disney", result.Suggestions[0].Origin);
}
// ── JSON parsing ────────────────────────────────────────────────────── // ── JSON parsing ──────────────────────────────────────────────────────
[Fact] [Fact]

View File

@@ -291,7 +291,7 @@ public class ApplicationContext : DbContext
(string Name, string Genotype, int SortOrder)[] catalog = (string Name, string Genotype, int SortOrder)[] catalog =
{ {
// --- C-locus white / partial albino (IDs 1-3) --- // --- C-locus white / partial albino (IDs 1-3) ---
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere", 0), ("REW", "AA chch DD EE GG pp spsp rere", 0),
("Hermelin", "aa chch DD EE GG PP spsp rere", 1), ("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
("Himalaya", "AA chch DD EE GG PP spsp rere", 2), ("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
// --- Zobel / colourpoint dark (ID 4) --- // --- Zobel / colourpoint dark (ID 4) ---

View File

@@ -0,0 +1,16 @@
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// WEB-2: path where POST /api/publish writes the rendered static site.
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
/// In production this volume is shared with the publicsite-nginx container.
/// </summary>
public sealed class PublicSiteOptions
{
public const string SectionName = "PublicSite";
public string? RootPath { get; set; }
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
}
}

View File

@@ -1,3 +1,4 @@
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms; using GerbilManagerWebAPI.Cms;
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models; using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Endpoints namespace GerbilManagerWebAPI.Endpoints
{ {
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList()); return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
}); });
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
{
if (!opts.Value.IsConfigured)
return Results.Problem(
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
statusCode: 503,
title: "PublicSite nicht konfiguriert");
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
return Results.Ok(new { filesPublished = files.Count });
});
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten) // ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative // und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im // Links/CSS der gerenderten Seite funktionieren dadurch im
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
return app; return app;
} }
/// <summary>
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
/// </summary>
internal static async Task PublishToDirectoryAsync(
IReadOnlyDictionary<string, string> files, string rootPath)
{
var stagingDir = Path.Combine(rootPath, "_staging_new");
var liveDir = Path.Combine(rootPath, "live");
var oldDir = Path.Combine(rootPath, "_old");
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
Directory.CreateDirectory(stagingDir);
foreach (var (relativePath, content) in files)
{
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.Combine(stagingDir, normalPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
}
// Atomic swap: _staging_new → live
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
Directory.Move(stagingDir, liveDir);
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
catch { /* non-fatal — old dir gone on next publish */ }
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary> /// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) => private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8" path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"

View File

@@ -22,7 +22,10 @@ namespace GerbilManagerWebAPI.Import
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields). /// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
/// ///
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef. /// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
/// Execute wraps all writes in a single transaction (atomic: crash → full rollback). /// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
/// to re-run on transient-failure retry.
/// Execute is gated by the endpoint; this service only acts when asked. /// Execute is gated by the endpoint; this service only acts when asked.
/// </summary> /// </summary>
public sealed class ImportDocxService public sealed class ImportDocxService
@@ -94,6 +97,11 @@ namespace GerbilManagerWebAPI.Import
.GroupBy(c => NormalizeName(c.Name)) .GroupBy(c => NormalizeName(c.Name))
.ToDictionary(g => g.Key, g => g.First().Id); .ToDictionary(g => g.Key, g => g.First().Id);
// Snapshot of DB contacts before any writes.
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
// but would remain in the in-memory dict without this reset).
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching) // ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
var colorVarietyByName = (await _db.ColorVarieties var colorVarietyByName = (await _db.ColorVarieties
.Select(cv => new { cv.Id, cv.Name }) .Select(cv => new { cv.Id, cv.Name })
@@ -105,19 +113,15 @@ namespace GerbilManagerWebAPI.Import
int ownerLinked = 0, ownerCreated = 0, skipped = 0; int ownerLinked = 0, ownerCreated = 0, skipped = 0;
// Ordinal counter for collision-free ExternalRef within this batch. // Ordinal counter for collision-free ExternalRef within this batch.
// Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes.
var externalRefOrdinals = new Dictionary<string, int>(); var externalRefOrdinals = new Dictionary<string, int>();
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run. // Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
var batchRefs = new HashSet<string>(); var batchRefs = new HashSet<string>();
// --- Planning pass (dry-run counts + execute writes) --- // Inner loop — shared by dry-run and execute paths.
// Execute path is wrapped in a single transaction for atomicity. // All local variables above are captured by reference (C# closure), so the strategy
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null; // lambda can reset them before each retry and RunLoopAsync sees the fresh state.
if (execute) async Task RunLoopAsync()
tx = await _db.Database.BeginTransactionAsync();
try
{ {
foreach (var da in docxAnimals) foreach (var da in docxAnimals)
{ {
@@ -276,21 +280,36 @@ namespace GerbilManagerWebAPI.Import
}); });
} }
} }
}
// Flush all gerbil inserts + enrich updates in one shot (within the tx) if (!execute)
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0) {
// Dry-run: just count, no writes, no transaction needed.
await RunLoopAsync();
}
else
{
// Execute: wrap the entire transaction in the execution strategy so that providers
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
// The lambda resets all mutable state at the top so retries start clean.
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Reset mutable state — idempotent on strategy retry
_db.ChangeTracker.Clear();
externalRefOrdinals.Clear();
batchRefs.Clear();
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
ownerLinked = 0; ownerCreated = 0; skipped = 0;
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
await using var tx = await _db.Database.BeginTransactionAsync();
await RunLoopAsync();
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
await tx.CommitAsync();
if (tx is not null) await tx.CommitAsync(); });
}
catch
{
// tx.DisposeAsync (in finally) rolls back if not committed
throw;
}
finally
{
if (tx is not null) await tx.DisposeAsync();
} }
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx."); notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class RenameREW : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "REW");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "Pink Eyed White (PEW)");
}
}
}

View File

@@ -216,7 +216,7 @@ namespace GerbilManagerWebAPI.Migrations
{ {
Id = new Guid("00000000-0000-0000-0000-000000000001"), Id = new Guid("00000000-0000-0000-0000-000000000001"),
CanonicalGenotype = "AA chch DD EE GG pp spsp rere", CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
Name = "Pink Eyed White (PEW)", Name = "REW",
SortOrder = 0 SortOrder = 0
}, },
new new

View File

@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
}; };
// Known usage codes → (German description, isThematic).
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
new(StringComparer.OrdinalIgnoreCase)
{
["norn"] = ("Nordische/Altnordische Etymologie", false),
["japa"] = ("Japanische Etymologie", false),
["mythg"] = ("Griechische Mythologie", false),
["ger"] = ("Germanische/Deutsche Etymologie", false),
["arb"] = ("Arabische Etymologie", false),
["disney"] = ("Disney-Charaktere", true),
["pokemon"] = ("Pokémon-Namen", true),
["encities"] = ("Namen englischer Städte", true),
["hrcities"] = ("Namen kroatischer Städte", true),
["usstates"] = ("Namen von US-Bundesstaaten", true),
};
public async Task<NameSuggestionResult> SuggestAsync( public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count, string? letter, string? gender, string? usages, int count,
CancellationToken ct = default) CancellationToken ct = default)
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
"Du bist ein Helfer für Rennmaus-Züchter. " + "Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " + "Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " + "KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch)."; "Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
"Erfinde KEINE Etymologie für thematische Kategorien.";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count) internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{ {
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
if (!string.IsNullOrWhiteSpace(gender) && if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase)) !gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere"); sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
bool hasThematic = false;
if (!string.IsNullOrWhiteSpace(usages)) if (!string.IsNullOrWhiteSpace(usages))
sb.Append($", aus den Kulturkreisen: {usages}"); {
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben "); var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var etymDescriptions = new List<string>();
var thematicDescriptions = new List<string>();
foreach (var code in codes)
{
if (UsageMap.TryGetValue(code, out var entry))
{
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
else etymDescriptions.Add(entry.Description);
}
else
{
etymDescriptions.Add(code);
}
}
if (etymDescriptions.Count > 0)
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
if (thematicDescriptions.Count > 0)
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
hasThematic = thematicDescriptions.Count > 0;
}
sb.Append(". ");
if (hasThematic)
{
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
}
else
{
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). "); sb.Append("(keine erfundenen oder zufälligen Namen). ");
}
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: "); sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]"); sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString(); return sb.ToString();

View File

@@ -48,6 +48,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client). // FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>( builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); http => http.Timeout = TimeSpan.FromSeconds(60));
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// AR-3: persist the key ring so encrypted passwords survive image redeployments. // AR-3: persist the key ring so encrypted passwords survive image redeployments.

View File

@@ -19,63 +19,61 @@ Feinschliff** — siehe unten._
| # | Was gebraucht wird | Schaltet frei | | # | Was gebraucht wird | Schaltet frei |
|---|---|---| |---|---|---|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. | | **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
| **A4** | **Domain-Name** (registriert) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. | | **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). | | ~~**A5**~~ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) | | **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager`**Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
## B. Kleine Aktion (jederzeit) ## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum | | # | Aktion | Warum |
|---|---|---| |---|---|---|
| **B2** | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt (http://192.168.2.124:5173). | | ~~**B2**~~ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
## C. Genetik-Feinschliff (optional, blockiert nichts) ## C. Genetik-Feinschliff (optional, blockiert nichts)
| # | Frage | Betrifft | | # | Frage | Betrifft |
|---|---|---| |---|---|---|
| **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. | | ~~**REW-1**~~ | **JA, PEW = REW (dasselbe)** PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
| **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). | | ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen | | **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften (optional) ## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?** **E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen · Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?)
- **E2** — Auch ehrliche **negative** Eigenschaften aufnehmen (z. B. *beißt manchmal*) für ehrliche Inserate?
--- ---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden) ## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.) Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten): **A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? | | Tier | Sterbedatum — welches? | Quelldatei(en) |
|---|---| |---|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | | Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | | Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 | | ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 | | ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | | Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum): **B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | | Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---| |---|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** | | Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
| Percy of little runners (*16.12.2017) | P-Locus: **PP** **Pp** | | ~~Percy of little runners (*16.12.2017)~~ | P-Locus **Pp** (erledigt) | — |
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** **Pp** | | ~~Iwana of little runners (*02.10.2018)~~ | P-Locus **Pp** (erledigt) | — |
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-****Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** | | ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) **c[chm]c[chm]** (Colourpoint) | | ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | | Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** | | ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | | Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)* *(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
--- ---
@@ -85,9 +83,9 @@ Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgeta
|---|---|---| |---|---|---|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — | | KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) | | E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
| Öffentliche Webseite + KI-Chat-Editor | gebaut | **A4** (Domain + Cloudflare) | | Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** | | NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) | | Handy-Zugriff im WLAN | **B2 Firewall erledigt** — testbar sobald App läuft | — |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) | | Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
--- ---

View File

@@ -21,6 +21,11 @@ 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
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
PUBLICSITE_PORT=8081
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf) # KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md # Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing) # Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)

View File

@@ -48,9 +48,12 @@ services:
AI__BaseUrl: "${AI__BaseUrl:-}" AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}" AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}" AI__Model: "${AI__Model:-gemini-flash-latest}"
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
PublicSite__RootPath: /data/publicsite
volumes: volumes:
- photos:/data/photos - photos:/data/photos
- keys:/data/keys - keys:/data/keys
- publicsite:/data/publicsite
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -74,6 +77,21 @@ services:
api: api:
condition: service_healthy condition: service_healthy
# --- nginx Public Site (WEB-2) ---
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
publicsite:
image: nginx:alpine
restart: unless-stopped
ports:
- "${PUBLICSITE_PORT:-8081}:80"
volumes:
- publicsite:/usr/share/nginx/html:ro
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) --- # --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
backup: backup:
image: postgres:17-alpine image: postgres:17-alpine
@@ -122,3 +140,10 @@ volumes:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
publicsite:
driver: local
driver_opts:
type: none
o: bind
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"

View File

@@ -0,0 +1,29 @@
# GerbilManager — publicsite nginx (WEB-2)
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
server {
listen 80;
root /usr/share/nginx/html/live;
index index.html;
charset utf-8;
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
location / {
try_files $uri $uri/index.html =404;
add_header Cache-Control "no-cache, must-revalidate";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
}
# CSS/Bilder: kurze TTL (1 Tag)
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
try_files $uri =404;
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Kein Zugriff auf Staging-Verzeichnisse
location ~ ^/_(staging_new|old)/ {
return 403;
}
}

View File

@@ -0,0 +1,37 @@
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
#
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
# er hat keine Authentifizierung und muss LAN-only bleiben.
server {
listen 80;
server_name <DOMAIN>;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Kein Buffering fuer kleine statische HTML-Seiten
proxy_buffering off;
}
}
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
# server {
# listen 443 ssl;
# server_name <DOMAIN>;
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
# location / {
# proxy_pass http://127.0.0.1:8081;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }

136
docs/web-deploy.md Normal file
View File

@@ -0,0 +1,136 @@
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
> **Zielgruppe:** Julian.
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
---
## Architektur
```
Internet
| HTTPS/HTTP
v
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
| proxy_pass http://127.0.0.1:8081
v
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
| (Shared Volume, read-only)
| [POST /api/publish im Manager schreibt in dasselbe Volume]
v
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
| (Shared Volume, read-write)
v
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
```
**SICHERHEIT — harte Bedingung:**
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
---
## Erstinstallation
### 1. Verzeichnis anlegen
```bash
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
```
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
### 2. .env erganzen
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
```env
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
PUBLICSITE_PORT=8081
```
### 3. Compose-Stack neu starten
```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d
```
Der neue `publicsite`-Container startet und serviert Port 8081.
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
### 4. Julians externen nginx konfigurieren
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
(als eigenen `server`-Block oder per `include`):
```bash
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
nano /etc/nginx/conf.d/gerbilmanager-public.conf
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
nginx -t && nginx -s reload
```
---
## Seite veroeffentlichen (Publish-Ablauf)
1. Im Manager einloggen (http://\<NAS-IP\>/)
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
**Was passiert intern:**
```
POST /api/publish
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
→ Schreibt Dateien nach /data/publicsite/_staging_new/
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
→ Kein Container-Restart, kein Image-Rebuild, kein CI
Response: { "filesPublished": N }
```
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
---
## Verifikation
```bash
# publicsite-Container laeuft?
docker compose -f deploy/truenas/compose.yaml ps publicsite
# Seite lokal abrufbar?
curl -s http://localhost:8081/ | head -5
# live/-Verzeichnis gefuellt?
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
# Oeffentlich erreichbar (nach DNS-Propagation)?
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
```
---
## Sicherheitstrennung (Pflichtcheck)
| Was | Port | Internet-exponiert? |
|-----|------|---------------------|
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
Sie sind ausschliesslich im Heimnetz erreichbar.
---
## Hostname noch ausstehend
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.

View File

@@ -110,3 +110,30 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expandBtn.click({ force: true }) await expandBtn.click({ force: true })
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 }) await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
}) })
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
await page.goto('/rennmaeuse/fridolin/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const panel = page.locator('.stammbaum-litters-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText(t.littersTitle)
await expect(panel).toContainText('Wurf K')
await expect(panel).toContainText('5')
// Link-Klick → Wurf-Detailseite
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
await expect(wurfLink).toBeVisible()
await wurfLink.click()
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
})
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
})

View File

@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
expect(codes).toContain('mythg') expect(codes).toContain('mythg')
expect(codes).toContain('ger') expect(codes).toContain('ger')
expect(codes).toContain('arb') expect(codes).toContain('arb')
expect(codes).toHaveLength(5) expect(codes).toContain('disney')
expect(codes).toContain('pokemon')
expect(codes).toContain('encities')
expect(codes).toContain('hrcities')
expect(codes).toContain('usstates')
expect(codes).toHaveLength(10)
}) })
it('every usage has a non-empty label', () => { it('every usage has a non-empty label', () => {

View File

@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
{ code: 'mythg', label: 'Griech. Mythologie' }, { code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' }, { code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' }, { code: 'arb', label: 'Arabisch' },
{ code: 'disney', label: 'Disney' },
{ code: 'pokemon', label: 'Pokémon' },
{ code: 'encities', label: 'Englische Städte' },
{ code: 'hrcities', label: 'Kroatische Städte' },
{ code: 'usstates', label: 'US-Bundesstaaten' },
] as const ] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code'] export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']

View File

@@ -46,8 +46,8 @@ describe('Fraction', () => {
describe('Genotype serialization', () => { describe('Genotype serialization', () => {
it('round-trips display string <-> structured form', () => { it('round-trips display string <-> structured form', () => {
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere') const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere') expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere') expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
}) })
it('parses multi-char C-series alleles via maximal munch', () => { it('parses multi-char C-series alleles via maximal munch', () => {
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
expect(g.P).toEqual(['P', 'p']) expect(g.P).toEqual(['P', 'p'])
}) })
it('wild type is AA CC DD EE GG PP spsp rere', () => { it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere') expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
}) })
}) })
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
expect(result.offspring).toHaveLength(1) expect(result.offspring).toHaveLength(1)
const only = result.offspring[0] const only = result.offspring[0]
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere') expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
expect(only.probability.text).toBe('1') expect(only.probability.text).toBe('1')
expect(result.warnings).toHaveLength(0) expect(result.warnings).toHaveLength(0)
}) })
@@ -185,15 +185,11 @@ describe('Farbschlag catalog', () => {
}) })
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 17 frozen ColorVariety keys — their representative genotype
// genotype MUST resolve back to their own name, never a later variety. // MUST resolve back to their own name, never a later variety.
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW' // GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
// because the REW engine check (both C-alleles reduced + pp) fires first. // chch+pp → REW engine check → 'REW' = entry.name.
// 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)
} }
}) })
@@ -224,11 +220,12 @@ describe('Farbschlag catalog', () => {
it('CATALOG seed view mirrors the ColorVariety table shape', () => { it('CATALOG seed view mirrors the ColorVariety table shape', () => {
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: 'REW', 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. // GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
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[\]?-]+){6,8}$/.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', () => {
@@ -289,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
it('always RENDERS G, never Uw (breeder preference)', () => { it('always RENDERS G, never Uw (breeder preference)', () => {
// Uw/uw is an input/import alias only; output must echo G/g. // Uw/uw is an input/import alias only; output must echo G/g.
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
'AA CC DD EE Gg PP spsp rere', 'AA CC DD EE Gg PP spsp',
) )
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw') expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
}) })
@@ -301,10 +298,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl']) expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
}) })
it('toDisplayString omits wild-type Sls but shows Slsl', () => { it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere') // GEN-4d: Re (rere) omitted at wildtype, like Sls.
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
// Rex het → Rere shown
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
'AA CC DD EE GG PP spsp Rere',
)
// WP → Slsl shown, rere still omitted
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp rere Slsl', 'AA CC DD EE GG PP spsp Slsl',
) )
}) })
@@ -334,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
describe('GEN-3a: flag/metadata tokens tolerated', () => { describe('GEN-3a: flag/metadata tokens tolerated', () => {
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => { it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV') const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere') expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
}) })
it('extractGenotypeFlags reads deafness + tags', () => { it('extractGenotypeFlags reads deafness + tags', () => {
@@ -367,11 +370,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
it("accepts '-' input, stores '?', displays '-'", () => { it("accepts '-' input, stores '?', displays '-'", () => {
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere') const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?' expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-' expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
}) })
it("'?' and '-' inputs are equivalent", () => { it("'?' and '-' inputs are equivalent", () => {
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
'Aa C- DD EE GG Pp spsp rere', 'Aa C- DD EE GG Pp spsp',
) )
}) })
}) })
@@ -396,10 +399,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
describe('GEN-3d: dominance tiebreak for unknown loci', () => { describe('GEN-3d: dominance tiebreak for unknown loci', () => {
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => { it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya. // 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere')) const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
expect(name).toBe('Schwarz') expect(name).toBe('Schwarz')
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name) expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
}) })
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => { it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
@@ -549,15 +552,16 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
}) })
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => { it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp // hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
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-Gold (A-)
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
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-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-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-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW) // het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW') expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW — subsumes PEW) // ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW') expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
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) // Counterproof: full C present → NOT REW (residual pigment)
@@ -593,15 +597,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => { it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom // Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp rere', 'AA CC DD e[f]e[f] GG PP spsp',
) )
// C-locus het: cchm + ch // C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp rere', 'aa c[chm]c[h] DD EE GG PP spsp',
) )
// C-locus hom cchm // C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp rere', 'aa c[chm]c[chm] DD EE GG PP spsp',
) )
}) })
@@ -611,24 +615,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Display must swap to [e, ef] per breeder convention. // Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere') const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere') expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
}) })
it('E+e stays Ee (E dominant over e, no swap needed)', () => { it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp rere', 'aa CC DD Ee GG PP spsp',
) )
}) })
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => { 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( expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp rere', 'aa CC DD Ee[f] GG PP spsp',
) )
}) })
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ───────────────────── // ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => { it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp rere' const display = 'aa C- D- ee[f] Gg Pp spsp'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e']) expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?']) expect(g.C).toEqual(['C', '?'])
@@ -636,16 +640,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is. // 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', () => { it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere' const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch']) expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e']) expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display) expect(toDisplayString(g)).toBe(display)
}) })
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => { it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere' const display = 'aa Cc[h] dd EE Gg P- Spsp'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch']) expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd']) expect(g.D).toEqual(['d', 'd'])
@@ -668,7 +672,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('e[-] standalone: parses as [e,?], displays e-', () => { it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere') const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?']) expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere') expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
}) })
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => { it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
@@ -680,6 +684,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.C).toEqual(['cchm', 'cchm']) expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd']) expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp']) expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere') expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
}) })
}) })

View File

@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is // GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception // Orangeschimmel — see the GEN-2 block below). This was an authorized exception
// to the frozen-name rule; the ColorVariety seed drops it too. // to the frozen-name rule; the ColorVariety seed drops it too.
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' }, { name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' }, { name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' }, { name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' }, { name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
@@ -268,7 +268,7 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent. // AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
// cchm/cchm + pp → REW (CP varieties with pink-eye) // cchm/cchm + pp → REW (CP varieties with pink-eye)
// cchm/ch + pp → REW (het colourpoint + pink-eye) // cchm/ch + pp → REW (het colourpoint + pink-eye)
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry) // ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here)
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains). // Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
const [c0, c1] = resolvedPair(g, 'C') const [c0, c1] = resolvedPair(g, 'C')
const [p0, p1] = resolvedPair(g, 'P') const [p0, p1] = resolvedPair(g, 'P')

View File

@@ -1,6 +1,6 @@
[ [
{ {
"name": "Pink Eyed White (PEW)", "name": "REW",
"english": "Pink Eyed White", "english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere", "canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"sortOrder": 0, "sortOrder": 0,

View File

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

View File

@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
} }
/** /**
* 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".
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and * The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers * Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl. * breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder * 8-locus strings and the colour catalog stay byte-identical; it only appears for
* convention) — e.g. ['C','?'] renders "C-". * WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]); * Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
* E-locus display order is E > e > e[f] (e before e[f] in het pairs). * convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
* order E > e > e[f].
*/ */
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 === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
) )
.map((locus) => { .map((locus) => {
const [a, b] = displayPair(locus, g[locus]) const [a, b] = displayPair(locus, g[locus])

View File

@@ -22,12 +22,13 @@ import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree' import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
import { de } from '../strings/de' import { de } from '../strings/de'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import { listLitters } from '../api/litters'
import { listColorVarieties } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { getInbreedingCoefficient } from '../api/pedigree' import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types' import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
ancestorsAt, ancestorsAt,
@@ -156,6 +157,13 @@ export default function StammbaumPage() {
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %` ? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
: t.inbreeding.unavailable : t.inbreeding.unavailable
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
const rootLitters = useApi(
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
[id],
)
const rootLitterItems = rootLitters.data?.items ?? []
/* ── react-d3-tree-Daten ── */ /* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root]) const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t]) const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
@@ -315,6 +323,10 @@ export default function StammbaumPage() {
</button> </button>
</div> </div>
<div className="stammbaum-layout">
{rootLitterItems.length > 0 && (
<LittersPanel litters={rootLitterItems} t={t} />
)}
<div className="stammbaum-canvas" ref={canvasRef}> <div className="stammbaum-canvas" ref={canvasRef}>
{view && ( {view && (
<Tree <Tree
@@ -335,6 +347,7 @@ export default function StammbaumPage() {
/> />
)} )}
</div> </div>
</div>
<ul className="stammbaum-hints"> <ul className="stammbaum-hints">
<li>{t.tapHint}</li> <li>{t.tapHint}</li>
<li>{t.hintName}</li> <li>{t.hintName}</li>
@@ -417,6 +430,36 @@ function PedigreeCard({
) )
} }
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
function LittersPanel({
litters,
t,
}: {
litters: Litter[]
t: { littersTitle: string; littersJunge: string }
}) {
return (
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
<ul className="stammbaum-litters-panel__list">
{litters.map((l) => (
<li key={l.id}>
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
<span className="stammbaum-litters-panel__name">{l.name}</span>
{l.totalBorn != null && (
<span className="stammbaum-litters-panel__born">
{l.totalBorn} {t.littersJunge}
</span>
)}
</Link>
</li>
))}
</ul>
</aside>
)
}
function SexIcon({ gender }: { gender: Gender }) { function SexIcon({ gender }: { gender: Gender }) {
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?' const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
return ( return (
@@ -512,7 +555,11 @@ function PrintCell({
</div> </div>
)} )}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>} {farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>} {g.genotype && gen <= 2 && (
<div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -128,7 +128,6 @@ export default function WurfDetailPage() {
</ul> </ul>
)} )}
<h3>{t.detail.expectedColors}</h3>
{expected ? ( {expected ? (
<BreedingResultView result={expected} title={t.detail.expectedColors} /> <BreedingResultView result={expected} title={t.detail.expectedColors} />
) : ( ) : (

View File

@@ -45,9 +45,132 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
/* ── Würfe-Panel + Layout (STAMMBAUM-LITTERS) ────────────────── */
.stammbaum-layout {
display: flex;
align-items: stretch;
gap: 0;
}
/* Desktop: Würfe-Panel links vom Baum. */
.stammbaum-litters-panel {
flex: none;
width: 148px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.35rem;
padding: 0.5rem 0.75rem 0.5rem 0;
border-right: 1px solid var(--color-border);
margin-right: 0;
}
.stammbaum-litters-panel__title {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.stammbaum-litters-panel__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
overflow-y: auto;
max-height: calc(clamp(18rem, 62dvh, 46rem) - 3rem);
}
.stammbaum-litters-panel__link {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.3rem 0.45rem;
border-radius: 0.4rem;
text-decoration: none;
color: inherit;
background: var(--color-surface);
border: 1px solid var(--color-border);
font-size: 0.8rem;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.stammbaum-litters-panel__link:hover {
background: var(--color-accent-soft);
border-color: var(--color-accent);
color: var(--color-accent);
}
.stammbaum-litters-panel__name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
font-size: 0.72rem;
}
/* Mobil (≤520px): Panel als kompakter horizontaler Streifen ÜBER dem Baum. */
@media (max-width: 520px) {
.stammbaum-layout {
flex-direction: column;
}
.stammbaum-litters-panel {
width: auto;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
border-right: none;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.stammbaum-litters-panel__title {
flex: none;
white-space: nowrap;
}
.stammbaum-litters-panel__list {
flex-direction: row;
flex-wrap: nowrap;
max-height: none;
overflow-x: auto;
overflow-y: hidden;
gap: 0.4rem;
}
.stammbaum-litters-panel__link {
flex-direction: row;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.stammbaum-litters-panel__born {
color: var(--color-text-muted);
}
/* Canvas behält explizite Höhe in column-Richtung. */
.stammbaum-canvas {
flex: 0 0 auto;
}
}
/* ── Zeichenfläche ────────────────────────────────────────────── */ /* ── Zeichenfläche ────────────────────────────────────────────── */
.stammbaum-canvas { .stammbaum-canvas {
flex: 1 1 auto;
min-width: 0;
height: clamp(18rem, 62dvh, 46rem); height: clamp(18rem, 62dvh, 46rem);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.6rem; border-radius: 0.6rem;

View File

@@ -13,6 +13,9 @@ export interface ChipColor {
const CHIP_COLORS: Record<string, ChipColor> = { const CHIP_COLORS: Record<string, ChipColor> = {
// Colourpoint / C-Serie // Colourpoint / C-Serie
// GEN-4c: PEW renamed to REW in catalog (Julian: PEW=REW). Both kept here for
// backward-compat during DB migration (old animals may still carry the old name).
'REW': { bg: '#f7f3ec', fg: '#8a7d6b' },
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' }, 'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' }, Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' }, Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },

View File

@@ -358,6 +358,9 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */ /** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen', hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen', hintExpand: ': weitere Vorfahren nachladen',
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe',
littersJunge: 'Junge',
zoomIn: 'Vergrößern', zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern', zoomOut: 'Verkleinern',
zoomFit: 'Ansicht einpassen', zoomFit: 'Ansicht einpassen',

View File

@@ -119,6 +119,49 @@
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)", "decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
"dateOfDeath": "12.12.2019", "dateOfDeath": "12.12.2019",
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)" "source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
},
{
"name": "Kazumi von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Filou von den Kleinen Chaoten",
"dob": "24.11.2014",
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
"dateOfDeath": "31.08.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sokrates von den Kleinen Chaoten",
"dob": "14.12.2015",
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
"dateOfDeath": "20.05.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Percy of little runners",
"dob": "16.12.2017",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Iwana of little runners",
"dob": "02.10.2018",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "Aa CC DD Ee Gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Osamu von den Kleinen Chaoten",
"dob": "10.12.2015",
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
"dateOfDeath": "18.12.2020",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
} }
] ]
} }