Compare commits
1 Commits
feature/fo
...
8f2b5600b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2b5600b3 |
@@ -1,143 +0,0 @@
|
|||||||
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,18 +39,19 @@ 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} — PATCH semantics: omitted/null fields keep the stored value.
|
// PUT /gerbils/{id}
|
||||||
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();
|
||||||
@@ -76,34 +77,27 @@ 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)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
g.Gender = i.Gender;
|
||||||
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;
|
g.LitterId = i.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
g.OriginContactId = i.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId;
|
||||||
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
g.EnclosureId = i.EnclosureId;
|
||||||
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
g.ColorVarietyId = i.ColorVarietyId;
|
||||||
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
g.DateOfBirth = i.DateOfBirth;
|
||||||
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
g.DateOfDeath = i.DateOfDeath;
|
||||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate;
|
||||||
g.Genotype = i.Genotype ?? g.Genotype;
|
g.Genotype = i.Genotype;
|
||||||
g.Notes = i.Notes ?? g.Notes;
|
g.Notes = i.Notes;
|
||||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
g.ImportSource = i.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
g.ExternalRef = i.ExternalRef;
|
||||||
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
g.OriginBreeder = i.OriginBreeder;
|
||||||
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
g.CharacterNote = i.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
g.IsDeaf = i.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,8 @@ 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).
|
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||||
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||||
/// 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,14 +137,12 @@ 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, g.ColorVarietyId }).ToListAsync();
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).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>();
|
||||||
@@ -171,8 +169,7 @@ 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;
|
||||||
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
||||||
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
|
||||||
@@ -463,51 +460,6 @@ 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.");
|
||||||
@@ -517,10 +469,6 @@ 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).");
|
||||||
@@ -536,8 +484,7 @@ 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)
|
||||||
@@ -627,8 +574,7 @@ 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);
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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$/)
|
|
||||||
})
|
|
||||||
@@ -149,8 +149,6 @@ export function seedDb(): MockDb {
|
|||||||
enclosureId: 'enc-leer',
|
enclosureId: 'enc-leer',
|
||||||
notes: 'neugieriger Entdecker',
|
notes: 'neugieriger Entdecker',
|
||||||
},
|
},
|
||||||
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
|
||||||
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const litters: Litter[] = [
|
const litters: Litter[] = [
|
||||||
|
|||||||
@@ -127,19 +127,6 @@ test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF
|
|||||||
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
await page.goto('/rennmaeuse')
|
|
||||||
// 'nameless-stub' hat name='' → Platzhalter '(ohne Namen)' soll als Link sichtbar sein
|
|
||||||
await expect(page.getByRole('link', { name: de.pages.gerbils.nameless })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Namenlose Tiere zeigen Platzhalter im Detail-Header (UI-POLISH-1)', async ({ page }) => {
|
|
||||||
skipUnlessMock()
|
|
||||||
await page.goto('/rennmaeuse/nameless-stub')
|
|
||||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.nameless })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
@@ -149,26 +136,3 @@ 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,8 +54,6 @@ 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. */
|
||||||
@@ -78,7 +76,6 @@ export interface CreateGerbil {
|
|||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
isDeaf?: boolean | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
const nameless = de.pages.gerbils.nameless
|
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import { andFilter, condition } from '../api/gridify'
|
import { andFilter, condition } from '../api/gridify'
|
||||||
import type { Gender, Gerbil, Paged } from '../api/types'
|
import type { Gender, Gerbil, Paged } from '../api/types'
|
||||||
@@ -64,7 +62,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{g.name || nameless}
|
{g.name}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -222,8 +222,9 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||||
// Every row has a non-empty canonical genotype display string and unique name.
|
// Every row has a non-empty canonical genotype display string and unique name.
|
||||||
|
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||||
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||||
@@ -523,3 +524,90 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
|
|||||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
|
||||||
|
// ── Display symbols ────────────────────────────────────────────────────
|
||||||
|
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||||
|
// Fuchsschimmel: E=[ef,ef] hom
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||||
|
'AA CC DD e[f]e[f] GG PP spsp rere',
|
||||||
|
)
|
||||||
|
// C-locus het: cchm + ch
|
||||||
|
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||||
|
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
||||||
|
)
|
||||||
|
// C-locus hom cchm
|
||||||
|
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||||
|
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── E-locus display order: E > e > e[f] ─────────────────────────────
|
||||||
|
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
|
||||||
|
// Stored canonical: [ef, e] (ef dominant over e in storage).
|
||||||
|
// Display must swap to [e, ef] per breeder convention.
|
||||||
|
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||||
|
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||||||
|
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||||
|
'aa CC DD Ee GG PP spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||||
|
'aa CC DD Ee[f] GG PP spsp rere',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||||
|
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||||||
|
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
||||||
|
const g = fromDisplayString(display)
|
||||||
|
expect(g.E).toEqual(['ef', 'e'])
|
||||||
|
expect(g.C).toEqual(['C', '?'])
|
||||||
|
expect(toDisplayString(g)).toBe(display)
|
||||||
|
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
||||||
|
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
||||||
|
const g = fromDisplayString(display)
|
||||||
|
expect(g.C).toEqual(['cchm', 'ch'])
|
||||||
|
expect(g.E).toEqual(['E', 'e'])
|
||||||
|
expect(toDisplayString(g)).toBe(display)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
||||||
|
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
||||||
|
const g = fromDisplayString(display)
|
||||||
|
expect(g.C).toEqual(['C', 'ch'])
|
||||||
|
expect(g.D).toEqual(['d', 'd'])
|
||||||
|
expect(toDisplayString(g)).toBe(display)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Parser accepts both forms ─────────────────────────────────────────
|
||||||
|
it('bracket input round-trips identically to internal-symbol input', () => {
|
||||||
|
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
|
||||||
|
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
|
||||||
|
)
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
|
||||||
|
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
|
||||||
|
)
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
|
||||||
|
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('[-] bracket-unknown parses as wildcard (e[-] → E=[e,?], displays e-)', () => {
|
||||||
|
// NOTE: oracle for Silvain shows "ee[-]" which contains 3 E-allele tokens
|
||||||
|
// (e + e + [-]) and cannot be parsed. Flagged to god — see done-report.
|
||||||
|
// This test documents what [e,?] at E produces: "e-".
|
||||||
|
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||||
|
expect(g.E).toEqual(['e', '?'])
|
||||||
|
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,35 +2,35 @@
|
|||||||
{
|
{
|
||||||
"name": "Pink Eyed White (PEW)",
|
"name": "Pink Eyed White (PEW)",
|
||||||
"english": "Pink Eyed White",
|
"english": "Pink Eyed White",
|
||||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hermelin",
|
"name": "Hermelin",
|
||||||
"english": "Dark Tailed White",
|
"english": "Dark Tailed White",
|
||||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 1,
|
"sortOrder": 1,
|
||||||
"image": "hermelin.jpeg"
|
"image": "hermelin.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Himalaya",
|
"name": "Himalaya",
|
||||||
"english": "Himalayan",
|
"english": "Himalayan",
|
||||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 2,
|
"sortOrder": 2,
|
||||||
"image": "himalaya.jpg"
|
"image": "himalaya.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel",
|
"name": "Zobel",
|
||||||
"english": "Sable",
|
"english": "Sable",
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 3,
|
"sortOrder": 3,
|
||||||
"image": "zobel.jpeg"
|
"image": "zobel.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotaugenschimmel",
|
"name": "Rotaugenschimmel",
|
||||||
"english": "Red-Eyed Roan",
|
"english": "Red-Eyed Roan",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 4,
|
"sortOrder": 4,
|
||||||
"image": "rotaugen-schimmel.jpg"
|
"image": "rotaugen-schimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Orangeschimmel",
|
"name": "Orangeschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 25,
|
"sortOrder": 25,
|
||||||
"image": "schimmel-orangeschimmel.jpg"
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -210,31 +210,31 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"name": "Silberschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 32,
|
"sortOrder": 32,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 33,
|
"sortOrder": 33,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 34,
|
"sortOrder": 34,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 35,
|
"sortOrder": 35,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||||
"sortOrder": 36,
|
"sortOrder": 36,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||||
"sortOrder": 42,
|
"sortOrder": 42,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 44,
|
"sortOrder": 44,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
@@ -318,89 +318,89 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marder",
|
"name": "Marder",
|
||||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 50,
|
"sortOrder": 50,
|
||||||
"image": "marder.JPG"
|
"image": "marder.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Siam",
|
"name": "Siam",
|
||||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 51,
|
"sortOrder": 51,
|
||||||
"image": "siam-marder-hell.JPG"
|
"image": "siam-marder-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel-Hell",
|
"name": "Zobel-Hell",
|
||||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 52,
|
"sortOrder": 52,
|
||||||
"image": "zobel-hell.jpg"
|
"image": "zobel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti",
|
"name": "CP-Agouti",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti-Hell",
|
"name": "CP-Agouti-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 54
|
"sortOrder": 54
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 55,
|
"sortOrder": 55,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti-Hell",
|
"name": "CP-Silberagouti-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
"sortOrder": 56
|
"sortOrder": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||||
"sortOrder": 57,
|
"sortOrder": 57,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs-Hell",
|
"name": "CP-Algierfuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||||
"sortOrder": 58
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||||
"sortOrder": 59,
|
"sortOrder": 59,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs-Hell",
|
"name": "CP-Polarfuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||||
"sortOrder": 60
|
"sortOrder": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||||
"sortOrder": 61
|
"sortOrder": 61
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"name": "CP-Fuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||||
"sortOrder": 62
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"name": "CP-Blaufuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
||||||
"sortOrder": 63
|
"sortOrder": 63
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"name": "CP-Orangeschimmel",
|
||||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 64
|
"sortOrder": 64
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel-Hell",
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||||
"sortOrder": 65
|
"sortOrder": 65
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ export function wildType(): Genotype {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN-3h: breeder bracket-notation display symbols.
|
||||||
|
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
|
||||||
|
*/
|
||||||
|
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
|
||||||
|
ef: 'e[f]',
|
||||||
|
cchm: 'c[chm]',
|
||||||
|
ch: 'c[h]',
|
||||||
|
}
|
||||||
|
function displaySymbol(allele: string): string {
|
||||||
|
return DISPLAY_SYMBOL[allele] ?? allele
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
|
||||||
|
* Storage/dominance order is E > ef > e; display swaps ef and e so that
|
||||||
|
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
|
||||||
|
*/
|
||||||
|
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
|
||||||
|
|
||||||
|
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
||||||
|
if (locus !== 'E') return pair
|
||||||
|
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
|
||||||
|
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||||
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
|
|||||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||||
* convention) — e.g. ['C','?'] renders "C-".
|
* convention) — e.g. ['C','?'] renders "C-".
|
||||||
|
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
||||||
|
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
||||||
*/
|
*/
|
||||||
export function toDisplayString(g: Genotype): string {
|
export function toDisplayString(g: Genotype): string {
|
||||||
return LOCUS_ORDER.filter(
|
return LOCUS_ORDER.filter(
|
||||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
)
|
)
|
||||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
.map((locus) => {
|
||||||
|
const [a, b] = displayPair(locus, g[locus])
|
||||||
|
return displaySymbol(a) + displaySymbol(b)
|
||||||
|
})
|
||||||
|
.map((s) => s.replace(/\?/g, '-'))
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +178,13 @@ function normalizeToken(tok: string): string | null {
|
|||||||
if (t === 'WP') t = 'Slsl'
|
if (t === 'WP') t = 'Slsl'
|
||||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
// Order matters: [-] must be replaced before the plain-dash rule below.
|
||||||
|
t = t.replace(/\[-\]/g, '?') // bracket-unknown [-] → internal wildcard
|
||||||
|
t = t.replace(/e\[f\]/g, 'ef') // Schimmel display form → internal
|
||||||
|
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||||
|
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||||
|
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
|
||||||
t = t.replace(/-/g, '?')
|
t = t.replace(/-/g, '?')
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function GerbilDetailPage() {
|
|||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
<GerbilProfilePhoto gerbilId={g.id} />
|
||||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
<h2>{g.name}</h2>
|
||||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||||
@@ -153,7 +153,7 @@ export default function GerbilDetailPage() {
|
|||||||
{g.status === 'Deceased' && (
|
{g.status === 'Deceased' && (
|
||||||
<>
|
<>
|
||||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
||||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
|
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{g.status === 'GivenAway' && (
|
{g.status === 'GivenAway' && (
|
||||||
@@ -190,7 +190,7 @@ export default function GerbilDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||||
<Row label={t.fields.notes} value={g.notes || null} />
|
<Row label={t.fields.notes} value={g.notes} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h3>{t.detail.genetics}</h3>
|
<h3>{t.detail.genetics}</h3>
|
||||||
|
|||||||
@@ -21,12 +21,10 @@ 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 = {
|
||||||
@@ -41,12 +39,10 @@ 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: {
|
||||||
@@ -61,12 +57,10 @@ 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,
|
||||||
@@ -80,12 +74,10 @@ 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +167,10 @@ 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}`)
|
||||||
@@ -336,16 +326,6 @@ 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
|
||||||
@@ -380,21 +360,6 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t.fields.isDeaf}</span>
|
|
||||||
<select
|
|
||||||
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value
|
|
||||||
set('isDeaf', v === '' ? null : v === 'true')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">{t.form.isDeafUnknown}</option>
|
|
||||||
<option value="true">{t.form.isDeafYes}</option>
|
|
||||||
<option value="false">{t.form.isDeafNo}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field field--check" title={t.form.isResidentHint}>
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
<span>{t.form.isResidentLabel}</span>
|
<span>{t.form.isResidentLabel}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -256,11 +256,11 @@ export default function GerbilsPage() {
|
|||||||
className="gerbil-row__check"
|
className="gerbil-row__check"
|
||||||
checked={selected.has(g.id)}
|
checked={selected.has(g.id)}
|
||||||
onChange={() => toggleSelect(g.id)}
|
onChange={() => toggleSelect(g.id)}
|
||||||
aria-label={g.name || t.nameless}
|
aria-label={g.name}
|
||||||
/>
|
/>
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">
|
<span className="gerbil-card__name">
|
||||||
{g.name || t.nameless}
|
{g.name}
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={t.externalTitle}>
|
<span className="badge badge--external" title={t.externalTitle}>
|
||||||
{t.externalBadge}
|
{t.externalBadge}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export const de = {
|
|||||||
pages: {
|
pages: {
|
||||||
gerbils: {
|
gerbils: {
|
||||||
title: 'Rennmäuse',
|
title: 'Rennmäuse',
|
||||||
nameless: '(ohne Namen)',
|
|
||||||
// Liste
|
// Liste
|
||||||
newButton: 'Neue Rennmaus',
|
newButton: 'Neue Rennmaus',
|
||||||
empty: 'Keine Rennmäuse gefunden.',
|
empty: 'Keine Rennmäuse gefunden.',
|
||||||
@@ -80,8 +79,6 @@ 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',
|
||||||
@@ -116,9 +113,6 @@ 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,9 +129,7 @@ def norm_zucht(z):
|
|||||||
if not z:
|
if not z:
|
||||||
return ""
|
return ""
|
||||||
n = z.lower()
|
n = z.lower()
|
||||||
# TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
|
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
|
||||||
# (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()]
|
||||||
@@ -139,15 +137,6 @@ 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,33 +280,6 @@ 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