Compare commits
11 Commits
feature/ui
...
259dd96c0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 259dd96c0e | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| e1e088f041 | |||
| 358e30db1f |
143
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
143
GerbilManager.Tests/PartialUpdateTests.cs
Normal 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") ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
// Request DTOs -----------------------------------------------------------
|
// Request DTOs -----------------------------------------------------------
|
||||||
|
|
||||||
public record GerbilInput(
|
public record GerbilInput(
|
||||||
string Name,
|
string? Name,
|
||||||
Gender Gender,
|
Gender? Gender,
|
||||||
GerbilStatus? Status,
|
GerbilStatus? Status,
|
||||||
Guid? LitterId,
|
Guid? LitterId,
|
||||||
Guid? OriginContactId,
|
Guid? OriginContactId,
|
||||||
|
|||||||
@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||||
Apply(g, input, isCreate: true);
|
Apply(g, input, isCreate: true);
|
||||||
db.Gerbils.Add(g);
|
db.Gerbils.Add(g);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /gerbils/{id}
|
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||||
{
|
{
|
||||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (g is null) return TypedResults.NotFound();
|
if (g is null) return TypedResults.NotFound();
|
||||||
g.Name = input.Name;
|
|
||||||
Apply(g, input, isCreate: false);
|
Apply(g, input, isCreate: false);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||||
|
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||||
|
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||||
|
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||||
|
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||||
|
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||||
{
|
{
|
||||||
g.Gender = i.Gender;
|
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||||
|
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||||
g.LitterId = i.LitterId;
|
g.LitterId = i.LitterId ?? g.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId;
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||||
g.EnclosureId = i.EnclosureId;
|
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||||
g.ColorVarietyId = i.ColorVarietyId;
|
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||||
g.DateOfBirth = i.DateOfBirth;
|
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||||
g.DateOfDeath = i.DateOfDeath;
|
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||||
g.CauseOfDeath = i.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||||
g.Genotype = i.Genotype;
|
g.Genotype = i.Genotype ?? g.Genotype;
|
||||||
g.Notes = i.Notes;
|
g.Notes = i.Notes ?? g.Notes;
|
||||||
g.ImportSource = i.ImportSource;
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef;
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||||
g.OriginBreeder = i.OriginBreeder;
|
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||||
g.CharacterNote = i.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
ResidencySummary? Residency = null);
|
ResidencySummary? Residency = null);
|
||||||
|
|
||||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
||||||
|
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
|
||||||
|
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
|
||||||
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
|
||||||
|
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
|
||||||
|
|
||||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
|
|||||||
@@ -137,12 +137,14 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||||
var existingRows = await _db.Gerbils
|
var existingRows = await _db.Gerbils
|
||||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
|
||||||
var gidByNameDob = new Dictionary<string, Guid>();
|
var gidByNameDob = new Dictionary<string, Guid>();
|
||||||
foreach (var g in existingRows)
|
foreach (var g in existingRows)
|
||||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||||
|
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
|
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||||
|
|
||||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||||
var plan = new List<AnimalPlan>();
|
var plan = new List<AnimalPlan>();
|
||||||
@@ -169,7 +171,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||||
|
|
||||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
||||||
|
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||||
@@ -460,6 +463,51 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
|
||||||
|
// Counts already-imported animals where the current extract matched a DIFFERENT
|
||||||
|
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
|
||||||
|
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||||
|
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||||
|
|
||||||
|
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||||
|
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||||
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
|
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
||||||
|
int herkunftBackfilled = 0;
|
||||||
|
{
|
||||||
|
var needsHerkunft = await _db.Gerbils
|
||||||
|
.Where(g => g.OriginBreeder == null && g.IsResident)
|
||||||
|
.Select(g => new { g.Id, g.LitterId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var g in needsHerkunft)
|
||||||
|
{
|
||||||
|
herkunftBackfilled++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
// Prefer the OriginBreeder of an existing parent (father first, then mother).
|
||||||
|
string? derived = null;
|
||||||
|
if (g.LitterId is not null)
|
||||||
|
{
|
||||||
|
var parentIds = await _db.Litters
|
||||||
|
.Where(l => l.Id == g.LitterId)
|
||||||
|
.Select(l => new { l.FatherId, l.MotherId })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (parentIds?.FatherId is Guid fid)
|
||||||
|
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
|
||||||
|
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||||
|
if (derived is null && parentIds?.MotherId is Guid mid)
|
||||||
|
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
|
||||||
|
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||||
|
if (row is not null && row.OriginBreeder is null)
|
||||||
|
row.OriginBreeder = derived ?? DefaultClanBreeder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (littersWithoutDate > 0)
|
if (littersWithoutDate > 0)
|
||||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
@@ -469,6 +517,10 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (parentFksBackfilled > 0)
|
if (parentFksBackfilled > 0)
|
||||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||||
|
if (herkunftBackfilled > 0)
|
||||||
|
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||||
|
if (farbschlagWouldRebackfill > 0)
|
||||||
|
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
if (conflictsResolvedByDecision > 0)
|
||||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||||
@@ -484,7 +536,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||||
|
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||||
}
|
}
|
||||||
|
|
||||||
private T? Load<T>(string file)
|
private T? Load<T>(string file)
|
||||||
@@ -574,7 +627,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||||
private sealed record AnimalPlan(
|
private sealed record AnimalPlan(
|
||||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
|
||||||
|
Guid? CurrentColorVarietyId = null);
|
||||||
|
|
||||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||||
|
|||||||
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* QA-REGRESSION-1: GEHEGE-RENAME Lock-in.
|
||||||
|
*
|
||||||
|
* Sichert das Verhalten von drei GEHEGE-RENAME-Änderungen ab, die leicht still
|
||||||
|
* brechen können (Redirects, Nav-Einträge, Landing-URL):
|
||||||
|
* (a) /becken/* → /gehege/* Redirect (bookmarks / externe Links)
|
||||||
|
* (b) Nav zeigt 'Gehege' + 🏜️, kein 'Becken', kein 'Start'-Eintrag
|
||||||
|
* (c) '/' (Index) landet auf /rennmaeuse (nicht mehr auf einer Startseite)
|
||||||
|
*
|
||||||
|
* Bereits abgedeckt (NICHT dupliziert):
|
||||||
|
* - gotoSection(de.nav.enclosures) navigiert zu Gehege-Heading: navigation.spec.ts:19-20
|
||||||
|
* - CRUD + 409 auf /gehege: becken-kontakte.spec.ts
|
||||||
|
* - Wurf-Link + Herkunft: tiere.spec.ts:101-128
|
||||||
|
*/
|
||||||
|
import { de, expect, test } from './fixtures'
|
||||||
|
|
||||||
|
// ── (a) /becken/* → /gehege/* Redirect ──────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: /becken leitet auf /gehege weiter (bookmark-safe)', async ({ page }) => {
|
||||||
|
await page.goto('/becken')
|
||||||
|
// React Router BeckenRedirect navigiert → /gehege; Gehege-Heading muss sichtbar sein
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/gehege$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: /becken/neu leitet auf /gehege/neu weiter (deep redirect)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/becken/neu')
|
||||||
|
// Neues-Gehege-Formular muss sichtbar sein, URL muss /gehege/neu sein
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.form?.createTitle ?? de.pages.becken.newButton })).toBeVisible({ timeout: 6_000 })
|
||||||
|
await expect(page).toHaveURL(/\/gehege\/neu$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── (b) Nav: 'Gehege' ✓, 'Becken' ✗, 'Start' ✗ ──────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: Nav hat "Gehege"-Eintrag mit Terrarium-Icon, kein "Becken"', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
|
|
||||||
|
// Gehege-Link soll vorhanden sein (via „Mehr"-Blatt auf Phone oder direkt auf Desktop)
|
||||||
|
// Wir öffnen „Mehr" um sekundäre Links sichtbar zu machen
|
||||||
|
const moreBtn = nav.getByRole('button', { name: de.nav.more })
|
||||||
|
if (await moreBtn.isVisible()) {
|
||||||
|
await moreBtn.click()
|
||||||
|
}
|
||||||
|
await expect(nav.getByRole('link', { name: de.nav.enclosures })).toBeVisible()
|
||||||
|
// "Becken" als separater Nav-Eintrag darf nicht mehr existieren
|
||||||
|
await expect(nav.getByRole('link', { name: 'Becken', exact: true })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: Nav hat keinen "Start"-Eintrag mehr (de.nav.home entfernt)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
// Warten bis die Tierliste geladen ist, dann Nav prüfen
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
|
// 'Start' (de.nav.home wurde entfernt) darf kein Nav-Link mehr sein
|
||||||
|
await expect(nav.getByRole('link', { name: 'Start', exact: true })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── (c) '/' landet auf /rennmaeuse ──────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: "/" leitet auf /rennmaeuse weiter — keine Startseite mehr', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/')
|
||||||
|
// URL muss /rennmaeuse sein (Navigate replace)
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||||
|
// Tierliste-Heading sichtbar — nicht die alte Startseite
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
// Alten Willkommen-Text darf es nicht mehr geben (de.pages.home wurde entfernt)
|
||||||
|
await expect(page.getByText('Willkommen')).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE-RENAME: 404-Seite "Zur Startseite" landet weiterhin auf Tierliste', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/diese-seite-gibt-es-nicht')
|
||||||
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
|
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||||
|
// Nach Klick auf "Zur Startseite" → '/' → redirect → /rennmaeuse
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||||
|
})
|
||||||
@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
|||||||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||||
|
// Titel zeigt '(ohne Namen)'
|
||||||
|
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||||||
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
|
})
|
||||||
|
|||||||
@@ -149,3 +149,26 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
|
||||||
|
await page.goto('/rennmaeuse/fridolin/bearbeiten')
|
||||||
|
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
|
||||||
|
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
|
||||||
|
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
// Standard: Unbekannt (null → value='')
|
||||||
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
|
||||||
|
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||||
|
})
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface Gerbil {
|
|||||||
* as resident (backend default true).
|
* as resident (backend default true).
|
||||||
*/
|
*/
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -76,6 +78,7 @@ export interface CreateGerbil {
|
|||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ interface FormState {
|
|||||||
enclosureId: string
|
enclosureId: string
|
||||||
litterId: string
|
litterId: string
|
||||||
originContactId: string
|
originContactId: string
|
||||||
|
originBreeder: string
|
||||||
receiverContactId: string
|
receiverContactId: string
|
||||||
genotype: string
|
genotype: string
|
||||||
notes: string
|
notes: string
|
||||||
isResident: boolean
|
isResident: boolean
|
||||||
|
isDeaf: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -39,10 +41,12 @@ const EMPTY: FormState = {
|
|||||||
enclosureId: '',
|
enclosureId: '',
|
||||||
litterId: '',
|
litterId: '',
|
||||||
originContactId: '',
|
originContactId: '',
|
||||||
|
originBreeder: '',
|
||||||
receiverContactId: '',
|
receiverContactId: '',
|
||||||
genotype: '',
|
genotype: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isResident: true,
|
isResident: true,
|
||||||
|
isDeaf: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -57,10 +61,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: string | null
|
enclosureId: string | null
|
||||||
litterId: string | null
|
litterId: string | null
|
||||||
originContactId: string | null
|
originContactId: string | null
|
||||||
|
originBreeder?: string | null
|
||||||
receiverContactId: string | null
|
receiverContactId: string | null
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
isResident?: boolean | null
|
isResident?: boolean | null
|
||||||
|
isDeaf?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
@@ -74,10 +80,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: g.enclosureId ?? '',
|
enclosureId: g.enclosureId ?? '',
|
||||||
litterId: g.litterId ?? '',
|
litterId: g.litterId ?? '',
|
||||||
originContactId: g.originContactId ?? '',
|
originContactId: g.originContactId ?? '',
|
||||||
|
originBreeder: g.originBreeder ?? '',
|
||||||
receiverContactId: g.receiverContactId ?? '',
|
receiverContactId: g.receiverContactId ?? '',
|
||||||
genotype: g.genotype ?? '',
|
genotype: g.genotype ?? '',
|
||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
isResident: g.isResident ?? true,
|
isResident: g.isResident ?? true,
|
||||||
|
isDeaf: g.isDeaf ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,10 +175,12 @@ export default function GerbilFormPage() {
|
|||||||
enclosureId: nn(form.enclosureId),
|
enclosureId: nn(form.enclosureId),
|
||||||
litterId: nn(form.litterId),
|
litterId: nn(form.litterId),
|
||||||
originContactId: nn(form.originContactId),
|
originContactId: nn(form.originContactId),
|
||||||
|
originBreeder: nn(form.originBreeder),
|
||||||
receiverContactId: nn(form.receiverContactId),
|
receiverContactId: nn(form.receiverContactId),
|
||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
isResident: form.isResident,
|
isResident: form.isResident,
|
||||||
|
isDeaf: form.isDeaf,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
@@ -326,6 +336,16 @@ export default function GerbilFormPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.originBreeder}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={form.originBreeder}
|
||||||
|
placeholder="z. B. Zucht der Kleinen Chaoten"
|
||||||
|
onChange={(e) => set('originBreeder', e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.receiver}</span>
|
<span>{t.fields.receiver}</span>
|
||||||
<select
|
<select
|
||||||
@@ -360,6 +380,21 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.fields.isDeaf}</span>
|
||||||
|
<select
|
||||||
|
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
set('isDeaf', v === '' ? null : v === 'true')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t.form.isDeafUnknown}</option>
|
||||||
|
<option value="true">{t.form.isDeafYes}</option>
|
||||||
|
<option value="false">{t.form.isDeafNo}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="field field--check" title={t.form.isResidentHint}>
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
<span>{t.form.isResidentLabel}</span>
|
<span>{t.form.isResidentLabel}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export default function StammbaumPage() {
|
|||||||
<>
|
<>
|
||||||
<section className="page stammbaum-screen">
|
<section className="page stammbaum-screen">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<h2>{t.titleFor(root.gerbil.name)}</h2>
|
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||||
{t.backToAnimal}
|
{t.backToAnimal}
|
||||||
@@ -377,7 +377,7 @@ function PedigreeCard({
|
|||||||
<div className="pedigree-card__body">
|
<div className="pedigree-card__body">
|
||||||
<div className="pedigree-card__name">
|
<div className="pedigree-card__name">
|
||||||
<SexIcon gender={g.gender} />
|
<SexIcon gender={g.gender} />
|
||||||
<span className="pedigree-card__nametext">{g.name}</span>
|
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
||||||
</div>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
@@ -439,7 +439,7 @@ function PrintPedigree({
|
|||||||
return (
|
return (
|
||||||
<div className="stammbaum-print">
|
<div className="stammbaum-print">
|
||||||
<header className="stammbaum-print__head">
|
<header className="stammbaum-print__head">
|
||||||
<h1>{t.titleFor(root.gerbil.name)}</h1>
|
<h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
|
||||||
<p className="stammbaum-print__meta">
|
<p className="stammbaum-print__meta">
|
||||||
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
||||||
</p>
|
</p>
|
||||||
@@ -494,7 +494,7 @@ function PrintCell({
|
|||||||
<div className={base} style={style}>
|
<div className={base} style={style}>
|
||||||
<div className="stammbaum-print__name">
|
<div className="stammbaum-print__name">
|
||||||
{sexSymbol}
|
{sexSymbol}
|
||||||
{g.name}
|
{g.name || de.pages.gerbils.nameless}
|
||||||
</div>
|
</div>
|
||||||
{g.dateOfBirth && (
|
{g.dateOfBirth && (
|
||||||
<div className="stammbaum-print__sub">
|
<div className="stammbaum-print__sub">
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
|
|||||||
checked={selectedIds.has(g.id)}
|
checked={selectedIds.has(g.id)}
|
||||||
onChange={() => toggleAnimal(g.id)}
|
onChange={() => toggleAnimal(g.id)}
|
||||||
/>
|
/>
|
||||||
<span className="wizard-pick__name">{g.name}</span>
|
<span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
|
||||||
<span className="wizard-pick__meta">
|
<span className="wizard-pick__meta">
|
||||||
{[
|
{[
|
||||||
genderLabel(g.gender),
|
genderLabel(g.gender),
|
||||||
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.animals}</dt>
|
<dt>{de.pages.vertraege.fields.animals}</dt>
|
||||||
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
|
<dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.price}</dt>
|
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ export default function WurfFormPage() {
|
|||||||
name: l.name,
|
name: l.name,
|
||||||
date: l.date ?? '',
|
date: l.date ?? '',
|
||||||
fatherId: l.fatherId ?? '',
|
fatherId: l.fatherId ?? '',
|
||||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
|
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
|
||||||
motherId: l.motherId ?? '',
|
motherId: l.motherId ?? '',
|
||||||
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
|
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
|
||||||
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||||
notes: l.notes ?? '',
|
notes: l.notes ?? '',
|
||||||
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="male"
|
gender="male"
|
||||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
|
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="female"
|
gender="female"
|
||||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
|
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export const de = {
|
|||||||
enclosure: 'Gehege',
|
enclosure: 'Gehege',
|
||||||
colorVariety: 'Farbschlag',
|
colorVariety: 'Farbschlag',
|
||||||
origin: 'Herkunft',
|
origin: 'Herkunft',
|
||||||
|
originBreeder: 'Herkunft (Freitext)',
|
||||||
|
isDeaf: 'Gehörlos',
|
||||||
receiver: 'Abnehmer',
|
receiver: 'Abnehmer',
|
||||||
genotype: 'Genotyp',
|
genotype: 'Genotyp',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
@@ -114,6 +116,9 @@ export const de = {
|
|||||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
isResidentLabel: 'Gehört zum eigenen Bestand',
|
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||||
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||||
|
isDeafUnknown: 'Unbekannt',
|
||||||
|
isDeafYes: 'Ja',
|
||||||
|
isDeafNo: 'Nein',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ def norm_zucht(z):
|
|||||||
if not z:
|
if not z:
|
||||||
return ""
|
return ""
|
||||||
n = z.lower()
|
n = z.lower()
|
||||||
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
|
# TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
|
||||||
|
# (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b.
|
||||||
|
n = re.sub(r"\bv\.\s?d\.", " ", n)
|
||||||
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
||||||
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
||||||
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
||||||
@@ -137,6 +139,15 @@ def norm_zucht(z):
|
|||||||
return ZUCHT_ALIASES.get(key, key)
|
return ZUCHT_ALIASES.get(key, key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_clan_zucht(z):
|
||||||
|
"""True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery.
|
||||||
|
|
||||||
|
Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten',
|
||||||
|
'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc.
|
||||||
|
"""
|
||||||
|
return norm_zucht(z) == "kleinechaote"
|
||||||
|
|
||||||
|
|
||||||
def canon_pair(raw):
|
def canon_pair(raw):
|
||||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||||
name, zucht = split_name_zucht(raw)
|
name, zucht = split_name_zucht(raw)
|
||||||
|
|||||||
@@ -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", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||||
|
|
||||||
|
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
||||||
|
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
||||||
|
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
||||||
|
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
|
||||||
|
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
|
||||||
|
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
|
||||||
|
e.is_clan_zucht("kleinen Chaoten"))
|
||||||
|
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
|
||||||
|
e.is_clan_zucht("v.d. Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
|
||||||
|
e.is_clan_zucht("ZdkC"))
|
||||||
|
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
|
||||||
|
e.is_clan_zucht("von den Kleinen Chaoten"))
|
||||||
|
check("KC-matcher: empty string -> NOT clan",
|
||||||
|
not e.is_clan_zucht(""))
|
||||||
|
check("KC-matcher: 'Black Forest' -> NOT clan",
|
||||||
|
not e.is_clan_zucht("Black Forest"))
|
||||||
|
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
|
||||||
|
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
|
||||||
|
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
|
||||||
|
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
|
||||||
|
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
|
||||||
|
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
|
||||||
|
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
|
||||||
|
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print(f"\n{failed} test(s) FAILED")
|
print(f"\n{failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user