REVIEW-FIXES-BACKEND: CR-9 + CR-11 + CR-10 + DB-1

CR-9 (major): gidByNameDob TryGetValue + ExternalRef-Fallback
  Verhindert KeyNotFoundException wenn Name/DOB zwischen zwei Laeufen driftet
  (z.B. correctDob-Remap oder UI-Umbenennung). Fallback: ExternalRef-Dict-Lookup;
  bei Miss: sauberes Ueberspringen + Note statt 500. +Test CR9_NameDOB_drift.

CR-11 (major): Farbschlag aus Genotyp ableiten (fill-NULL-only)
  Deep-Band-Tiere (gen>=2, kein Farbschlag-Feld) landen nicht laenger mit null
  ColorVariety. GenotypePotentiallyMatches() vergleicht locus-pair-weise (??=wildcard,
  case-insensitive). Nur vollstaendige Genotypen (8 Loci, kein ??) loesen Ableitung aus.
  Plan-Loop: fuellt colorVarietyId bei null + vollstaendigem Genotyp.
  Post-Sweep: bestehende DB-Tiere mit null ColorVarietyId werden nachgefuellt.
  AnimalSummary.FarbschlagDerivedFromGenotype = Zaehler. +Test CR11_ColorVariety_from_geno.

CR-10 (major, Python): malformed Override-Genotyp wird nicht angewendet
  apply_conflict_decisions validiert mapped8locus nach gt.parse(). Leeres Ergebnis
  = Genotyp unveraendert + decisionWarning statt stillem Blanken. Konflikt wird
  trotzdem aufgeloest (Entscheidung gilt, nur Genotyp-Override ausgelassen).
  +5 Python-Tests (CR-10-Block in test_extract.py).

DB-1 (high): filtered unique index auf Gerbil.ExternalRef
  WHERE ExternalRef IS NOT NULL — verhindert doppelten Import bei Race-Conditions
  oder Lauf-Ueberschneidungen. Migration UniqueExternalRef. SQLite-Testhost:
  HasFilter() wird via EnsureCreated appliziert (SQLite unterstuetzt Partial-Indexes).
  PartialUpdateTests externalRef-Assertion auf NotNull geaendert (name-hash unique).

GATE: 139/139 C# + Python ALL PASS, ef has-pending=No.
This commit is contained in:
2026-06-06 17:54:06 +02:00
parent f6a4025d71
commit 615abfa510
10 changed files with 1631 additions and 8 deletions

View File

@@ -580,6 +580,74 @@ namespace GerbilManager.Tests
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 ----
private const string LittersJson = """
[