Compare commits

..

13 Commits

Author SHA1 Message Date
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
90f998d189 Merge feature/animal-litters (ANIMAL-LITTERS): 'Wuerfe als Elternteil' auf Tier-Detailseite
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 59s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
GerbilDetailPage: Sektion listet Wuerfe wo fatherId|motherId==Tier (Gridify OR-Filter),
je Link /wuerfe/{id} + Vater/Mutter-Badge + Datum + totalBorn; Leer-Zustand. Rueckweg Tier→Wuerfe.
Frontend-only. vitest 104, e2e 160.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:51:10 +02:00
27 changed files with 2508 additions and 42 deletions

View File

@@ -52,7 +52,11 @@ jobs:
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
run: dotnet tool update --global dotnet-ef --version 10.0.*
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
run: |
dotnet tool update --global dotnet-ef --version 10.0.*
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: DB-3 EF Migrations Drift-Check
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -905,6 +905,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<string>("LitterLetter")
.HasColumnType("text");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");

View File

@@ -30,5 +30,9 @@ namespace GerbilManagerWebAPI.Models
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
/// Name+Date is the fallback for litters created before this column existed.</summary>
public string? ExternalRef { get; set; }
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
}
}

View File

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

View File

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

View File

@@ -45,6 +45,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
@@ -100,6 +103,7 @@ app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.Run();

View File

@@ -128,7 +128,7 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -179,17 +179,21 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag')
})
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
expect(CATALOG_SIZE).toBe(66)
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
expect(CATALOG_SIZE).toBe(70)
})
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
// The first 18 are the frozen ColorVariety keys — their representative
// genotype MUST resolve back to their own name, never a later variety.
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
// because the REW engine check (both C-alleles reduced + pp) fires first.
// PEW stays in the catalog as a user-pickable import name; its computed
// farbschlag is intentionally 'REW' per Julian's extended rule.
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
if (REW_SHADOWED.has(entry.name)) continue
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
}
})
@@ -421,7 +425,8 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
})
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -471,8 +476,8 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
@@ -481,11 +486,12 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
})
})
@@ -507,7 +513,8 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
})
@@ -525,6 +532,43 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
})
})
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('dd entries use Dilute prefix — no more X-dd names', () => {
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
})
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW — subsumes PEW)
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
// Counterproof: full C present → NOT REW (residual pigment)
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
})
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
})
})
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
// ── Display symbols ────────────────────────────────────────────────────
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {

View File

@@ -66,8 +66,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
{ name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
@@ -82,10 +82,16 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
// agouti+dilute+fox combinations not otherwise covered in the catalog.
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
@@ -104,8 +110,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -233,7 +239,13 @@ function colourpointName(g: Genotype): string | null {
}
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
const DILUTE = 'Dilute '
if (base.startsWith(DILUTE)) {
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
}
return `CP-${base}${bothCchm ? '' : '-Hell'}`
}
export function farbschlagFor(g: Genotype): FarbschlagMatch {
@@ -241,6 +253,20 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
// cchm/cchm + pp → REW (CP varieties with pink-eye)
// cchm/ch + pp → REW (het colourpoint + pink-eye)
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
const [c0, c1] = resolvedPair(g, 'C')
const [p0, p1] = resolvedPair(g, 'P')
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
const name = ['REW', ...modifiers].join(' ')
return { name, base: null, unknown: false }
}
const baseName = colourpointName(g) ?? baseColourFor(g)
if (!baseName) {
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }

View File

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

View File

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

View File

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

View File

@@ -105,6 +105,11 @@ export const de = {
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
notFound: 'Diese Rennmaus wurde nicht gefunden.',
moreData: 'Weitere Daten',
// ANIMAL-LITTERS: Würfe als Elternteil
parentLittersTitle: 'Würfe als Elternteil',
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter',
},
// Formular (anlegen/bearbeiten)
form: {
@@ -813,6 +818,19 @@ export const de = {
resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen',
},
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
namegen: {
button: 'Name vorschlagen',
panelTitle: 'Namensvorschläge',
letterLabel: 'Anfangsbuchstabe',
letterPlaceholder: 'z. B. A',
usagesLabel: 'Herkunftskultur',
loadButton: 'Vorschläge laden',
loading: 'Lade Vorschläge …',
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
close: 'Schließen',
},
} as const
export type Strings = typeof de

View File

@@ -105,6 +105,13 @@
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
"dateOfDeath": "17.04.2016",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
},
{
"name": "Kazu von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
}
]
}