Compare commits

..

2 Commits

Author SHA1 Message Date
258d6cf932 CR-1a: ee[-] Parser-Fix (Silvain) — lookbehind trennt e[-] vom führenden Allel
normalizeToken: lookbehind-Regex /(?<=[A-Za-z])e\[-\]/→'?' greift wenn e[-]
von einem Buchstaben (erstes Allel) gefolgt wird (ee[-] → e?); standalone e[-]
fällt durch auf die generische \[-\]→? Regel (→ e?). Beide Pfade liefern
splitToken 2 Allele [e,?]. Vorher: ee[-]→ee?→ 3 Allele → Fehler.
Fixture: Silvain 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp' → E=[e,?] ✓.
Gate: build ✓  eslint ✓  vitest 92/92 ✓  e2e 126/126 ✓
2026-06-06 17:42:30 +02:00
8f2b5600b3 GEN-3h: Breeder bracket-notation display + E-locus e-vor-e[f] Sortierung
- genotype.ts: toDisplayString zeigt ef→e[f], cchm→c[chm], ch→c[h] (Display-only;
  Storage-Contract unverändert). E-Locus Display-Rank E>e>ef: {ef,e} Paar
  rendert als ee[f] statt e[f]e (Züchterin-Konvention, Julian-Feedback).
- genotype.ts: normalizeToken akzeptiert Klammer-Eingabe (e[f], c[chm], c[h],
  [-]) → interne Symbole; vollständiger Round-Trip Display→Parse.
- genetics.test.ts: Katalog-Regex auf [A-Za-z[\]?-]+ erweitert; GEN-3h
  Notation-Fixtures (C/Zuleika/Milka-Orakel, Klammer-Round-Trip, E-Sortierung).
- colorVarietySeed.generated.json: neu generiert (66 Zeilen mit Klammer-Notation).
Gate: build ✓  eslint ✓  vitest 91/91 ✓  e2e 120/120 ✓
2026-06-06 17:04:54 +02:00
30 changed files with 58 additions and 2157 deletions

View File

@@ -48,19 +48,6 @@ jobs:
- name: Build - name: Build
run: dotnet build GerbilManager.slnx --no-restore -c Release run: dotnet build GerbilManager.slnx --no-restore -c Release
- name: dotnet-ef Tool installieren
run: dotnet tool install --global dotnet-ef --version 10.0.*
- name: DB-3 EF Migrations Drift-Check
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
# entities/OnModelCreating without generating a matching migration). Catches exactly
# the class of drift the SQLite/EnsureCreated test host is blind to.
run: >
dotnet ef migrations has-pending-model-changes
--project GerbilManagerWebAPI
--startup-project GerbilManagerWebAPI
--no-build -c Release
- name: Tests ausfuehren - name: Tests ausfuehren
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal" run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"

3
.gitignore vendored
View File

@@ -134,6 +134,3 @@ entities.json
# Runtime photo store (uploaded/imported gerbil photos) — never commit # Runtime photo store (uploaded/imported gerbil photos) — never commit
GerbilManagerWebAPI/photo-storage/ GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/

View File

@@ -580,74 +580,6 @@ namespace GerbilManager.Tests
Assert.Equal(new DateOnly(y, m, d), date); Assert.Equal(new DateOnly(y, m, d), date);
} }
[Fact]
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
{
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
// stable ExternalRef without throwing.
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
// Run 1: load the animal normally
await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, await db.Gerbils.CountAsync());
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
g.Name = "Umbenannt Tier";
await db.SaveChangesAsync();
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
{
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
// Exact Agouti genotype, no explicit Farbschlag name
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
"conflict":false}]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
// ColorVarietyId must be set even though no explicit Farbschlag name was given
Assert.NotNull(tier.ColorVarietyId);
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
Assert.Equal("Agouti", variety!.Name);
// Report counter should reflect the genotype derivation
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
// ---- fixtures ---- // ---- fixtures ----
private const string LittersJson = """ private const string LittersJson = """
[ [

View File

@@ -1,145 +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")
{
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
// when multiple tests in the same fixture share the SQLite connection.
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-{name.GetHashCode():X8}",
}));
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.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
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.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
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.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
}
[Fact]
public async Task Create_with_minimal_body_uses_safe_defaults()
{
// A minimal POST (only name) must still produce a valid gerbil
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Active", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}
}

View File

@@ -81,12 +81,6 @@ public class ApplicationContext : DbContext
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.ColorVariety).WithMany() e.HasOne(g => g.ColorVariety).WithMany()
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
e.HasIndex(g => g.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
}); });
modelBuilder.Entity<Litter>(e => modelBuilder.Entity<Litter>(e =>

View File

@@ -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,

View File

@@ -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);
} }

View File

@@ -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,
@@ -102,8 +98,7 @@ namespace GerbilManagerWebAPI.Import
int AlreadyImported, int AlreadyImported,
QuarantineSummary Quarantined, QuarantineSummary Quarantined,
int ParentLinksFromChart = 0, int ParentLinksFromChart = 0,
int ConflictsResolvedByDecision = 0, int ConflictsResolvedByDecision = 0);
int FarbschlagDerivedFromGenotype = 0);
public sealed record QuarantineSummary( public sealed record QuarantineSummary(
int Conflicts, int Conflicts,

View File

@@ -137,71 +137,31 @@ 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);
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
.ToDictionary(g => g.ExternalRef!, g => g.Id);
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
var varietiesWithGeno = await _db.ColorVarieties
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
// 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>();
int fbDerivedFromGenotype = 0;
foreach (var a in loadable) foreach (var a in loadable)
{ {
bool exists = existingGerbilSet.Contains(a.Id); bool exists = existingGerbilSet.Contains(a.Id);
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
Guid gid;
if (exists)
{
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
{
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
else
{
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
continue;
}
}
}
else gid = Guid.NewGuid();
Guid? wurfLitterId = null; Guid? wurfLitterId = null;
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 } if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid)) && litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
wurfLitterId = lid; wurfLitterId = lid;
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
Guid? colorVarietyId = null; Guid? colorVarietyId = null;
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants) var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
.Where(s => !string.IsNullOrWhiteSpace(s)); .Where(s => !string.IsNullOrWhiteSpace(s));
foreach (var fb in fbCandidates) foreach (var fb in fbCandidates)
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid)) if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
{ colorVarietyId = vid; break; } { colorVarietyId = vid; break; }
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
{
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
// would match any catalog entry via wildcards and produce false positives.
var composed = ComposeGenotype(a.Genotype);
if (!composed.Contains("??"))
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
}
var gender = InferGender(a, sireNames, damNames); var gender = InferGender(a, sireNames, damNames);
var norm = Normalize(StripZucht(a.Name)); var norm = Normalize(StripZucht(a.Name));
@@ -209,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
@@ -501,78 +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);
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
{
var noColor = await _db.Gerbils
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
.Select(g => new { g.Id, g.Genotype })
.ToListAsync();
foreach (var g in noColor)
{
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
Guid? derivedVid = null;
foreach (var v in varietiesWithGeno)
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
{ derivedVid = v.Id; break; }
if (derivedVid is null) continue;
fbDerivedFromGenotype++;
if (execute)
{
var row = await _db.Gerbils.FindAsync(g.Id);
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
}
}
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
}
// 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.");
@@ -582,15 +469,9 @@ 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).");
if (fbDerivedFromGenotype > 0)
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport( return new ImportReport(
@@ -599,12 +480,11 @@ namespace GerbilManagerWebAPI.Import
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype), parentLinksAdded, conflictsResolvedByDecision),
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)
@@ -637,23 +517,6 @@ namespace GerbilManagerWebAPI.Import
private static string StripCaret(string allele) => allele.Replace("^", ""); private static string StripCaret(string allele) => allele.Replace("^", "");
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
{
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (a.Length < 8 || c.Length < 8) return false;
for (int i = 0; i < 8; i++)
{
if (a[i] == "??" || c[i] == "??") continue;
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
}
return true;
}
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams) private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
{ {
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it // Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
@@ -711,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);

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class UniqueExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Gerbils_ExternalRef",
table: "Gerbils");
}
}
}

View File

@@ -807,10 +807,6 @@ namespace GerbilManagerWebAPI.Migrations
b.HasIndex("EnclosureId"); b.HasIndex("EnclosureId");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("LitterId"); b.HasIndex("LitterId");
b.HasIndex("OriginContactId"); b.HasIndex("OriginContactId");

View File

@@ -1,6 +1,5 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints; using GerbilManagerWebAPI.Endpoints;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore; using Scalar.AspNetCore;
@@ -47,17 +46,7 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// AR-3: persist the key ring so encrypted passwords survive image redeployments. builder.Services.AddDataProtection();
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
{
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
Directory.CreateDirectory(keyRingPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
.SetApplicationName("GerbilManager");
}
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>(); builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>(); builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>(); builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();

View File

@@ -15,15 +15,9 @@ TAG=latest
PGDATA_PATH=/mnt/SSD/gerbil/pgdata PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups BACKUPS_PATH=/mnt/SSD/gerbil/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf) # Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md ANTHROPIC_API_KEY=
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
AI__BaseUrl=
AI__ApiKey=
AI__Model=gemini-2.0-flash

View File

@@ -40,17 +40,10 @@ services:
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}" ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos) # Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
Photos__RootPath: /data/photos Photos__RootPath: /data/photos
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments) # KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
DataProtection__KeyRingPath: /data/keys ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-2.0-flash}"
volumes: volumes:
- photos:/data/photos - photos:/data/photos
- keys:/data/keys
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -108,14 +101,6 @@ volumes:
type: none type: none
o: bind o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}" device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys:
driver: local
driver_opts:
type: none
o: bind
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
backups: backups:
driver: local driver: local
driver_opts: driver_opts:

View File

@@ -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$/)
})

View File

@@ -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[] = [

View File

@@ -53,12 +53,3 @@ 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)
})

View File

@@ -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')
})

View File

@@ -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). */

View File

@@ -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>
))} ))}

View File

@@ -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>

View File

@@ -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

View File

@@ -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}

View File

@@ -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 || de.pages.gerbils.nameless)}</h2> <h2>{t.titleFor(root.gerbil.name)}</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 || de.pages.gerbils.nameless}</span> <span className="pedigree-card__nametext">{g.name}</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 || de.pages.gerbils.nameless)}</h1> <h1>{t.titleFor(root.gerbil.name)}</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 || de.pages.gerbils.nameless} {g.name}
</div> </div>
{g.dateOfBirth && ( {g.dateOfBirth && (
<div className="stammbaum-print__sub"> <div className="stammbaum-print__sub">

View File

@@ -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 || de.pages.gerbils.nameless}</span> <span className="wizard-pick__name">{g.name}</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 || de.pages.gerbils.nameless).join(', ')}</dd> <dd>{selectedAnimals.map((g) => g.name).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>

View File

@@ -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) || de.pages.gerbils.nameless) : '', fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
motherId: l.motherId ?? '', motherId: l.motherId ?? '',
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '', motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
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 || de.pages.gerbils.nameless }))} onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
/> />
)} )}
</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 || de.pages.gerbils.nameless }))} onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
/> />
)} )}
</fieldset> </fieldset>

View File

@@ -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 …',

View File

@@ -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)
@@ -973,19 +962,7 @@ def apply_conflict_decisions(merged, conflicts, path):
continue continue
a["resolvedByDecision"] = True a["resolvedByDecision"] = True
if d.get("genotype"): if d.get("genotype"):
# CR-10: validate the parsed genotype — a typo'd decision string yields empty a["genotype"] = gt.parse(d["genotype"])
# mapped8locus and would silently blank the animal's genotype while marking it
# 'resolved'. Only apply if the parse produces non-empty loci.
parsed = gt.parse(d["genotype"])
if parsed.get("mapped8locus"):
a["genotype"] = parsed
else:
# Keep the existing genotype; flag as a warning in the report.
a.setdefault("decisionWarnings", []).append(
f"Ungültiger Override-Genotyp '{d['genotype']}'"
"konnte nicht geparst werden (mapped8locus leer). "
"Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
)
if d.get("farbschlag"): if d.get("farbschlag"):
a["farbschlag"] = d["farbschlag"] a["farbschlag"] = d["farbschlag"]
a["farbschlagVariants"] = [d["farbschlag"]] a["farbschlagVariants"] = [d["farbschlag"]]

View File

@@ -280,63 +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"))
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
_json.dump({"resolutions": [
# Valid decision (genotype parses OK) -> should be applied
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
"genotype": "BLÖDSINN!!!", "source": "test"},
]}, open(dec_cr10, "w", encoding="utf-8"))
merged_cr10 = [
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
]
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
check("CR-10: valid decision genotype is applied (A-locus updated)",
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
check("CR-10: malformed decision still un-quarantines the animal",
merged_cr10[1].get("conflict") is False)
check("CR-10: malformed decision adds a decisionWarning",
bool(merged_cr10[1].get("decisionWarnings")))
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
try: os.remove(dec_cr10)
except OSError: pass
# --- 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)