Compare commits

...

10 Commits

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

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

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

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

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

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

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

+4 Regressionstests (PartialUpdateTests): ForSale-Toggle, Charakterbogen-Save,
EditForm-ohne-Import-Felder, minimaler POST — alle grueen (137/137 gesamt).
2026-06-06 17:40:15 +02:00
319aca7608 Merge feature/ui-polish: namenlose Tiere Platzhalter '(ohne Namen)' + Leerwert-'—'-Konsistenz
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 56s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
- de.ts nameless + GerbilsPage/GerbilDetailPage/AnimalPicker zeigen '(ohne Namen)' statt leer
- causeOfDeath/notes via || null → Rows zeigen '—' konsistent (Herkunft war schon korrekt)
- +4 e2e (Platzhalter Liste+Detail x2 Viewports). vitest 82, e2e 130, build clean. Kein EF.
[god-QA: disjunkt zu qa-regression+import-backfill-Merges]

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:58 +02:00
01781f23d6 Merge feature/qa-regression: e2e-Regressionsnetz fuer gehege/wurf-link/herkunft
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 53s
gehege-regression.spec.ts (12 Tests, 6x2 Viewports): /becken→/gehege Redirect (auch /neu),
Nav zeigt Gehege/kein Becken/kein Start, '/' → /rennmaeuse, 404-Link landet auf Tierliste.
WURF-LINK + Herkunft waren bereits in tiere.spec abgedeckt. e2e-only, kein EF.
[god-QA: 0 behind main, single new file]

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

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

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

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

GATE: 133/133 C#-Tests, 57 Python-Tests, ef has-pending=No.
2026-06-06 17:29:02 +02:00
16 changed files with 450 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -149,3 +149,26 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
})
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
await page.goto('/rennmaeuse/fridolin/bearbeiten')
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
})
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/bearbeiten')
// Standard: Unbekannt (null → value='')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
await page.getByRole('button', { name: t.form.save, exact: true }).click()
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -80,6 +80,8 @@ export const de = {
enclosure: 'Gehege',
colorVariety: 'Farbschlag',
origin: 'Herkunft',
originBreeder: 'Herkunft (Freitext)',
isDeaf: 'Gehörlos',
receiver: 'Abnehmer',
genotype: 'Genotyp',
notes: 'Notizen',
@@ -114,6 +116,9 @@ export const de = {
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
isDeafUnknown: 'Unbekannt',
isDeafYes: 'Ja',
isDeafNo: 'Nein',
save: 'Speichern',
cancel: 'Abbrechen',
saving: 'Speichern …',

View File

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

View File

@@ -280,6 +280,33 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
e.is_clan_zucht("kleinen Chaoten"))
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
e.is_clan_zucht("v.d. Kleinen Chaoten"))
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
e.is_clan_zucht("ZdkC"))
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
e.is_clan_zucht("von den Kleinen Chaoten"))
check("KC-matcher: empty string -> NOT clan",
not e.is_clan_zucht(""))
check("KC-matcher: 'Black Forest' -> NOT clan",
not e.is_clan_zucht("Black Forest"))
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)