genotype.py: - Uw/uw aliased to G/g (same locus) so the D2 conflict group + pure-Uw cases stop being conflicts (Gg == Uwuw). - Sls/WP recognized as a SECOND spotting locus (S(l)s(l)=WP het); carried into mapped8locus alongside Sp (Sp+Sls = Superschecke). - dea/Dea/taub/hörend -> hearing/deaf phenotype FLAG (not a locus). - WFNZ/RV/GV/DP -> provenance/breeding tags (not genotype, not conflicts). - test_genotype.py: zero-dep unit tests for all four. extract.py: surface deaf+tags on animals; dedup conflict detection now compares the NORMALIZED genotype key (mapped8locus) instead of the raw string, so Uw=G no longer triggers a conflict. Result: Konflikte 32 -> 27, Zucht-Splits stays 0. Dedup identity = name + DOB + Zucht. Backend: Gerbil.IsDeaf (bool?) + additive migration AddGerbilDeafFlag (has-pending-model-changes clean) + GerbilDto/GerbilInput round-trip. ImportService sets IsDeaf from animal.deaf and preserves Sls + tags + deaf in RawImportData (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
8.3 KiB
C#
180 lines
8.3 KiB
C#
using GerbilManagerWebAPI.Import;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GerbilManager.Tests
|
|
{
|
|
/// <summary>
|
|
/// FEAT-8c loader tests against a small JSON fixture + an in-memory DB (never Julian's
|
|
/// instance). Covers categorisation, the quarantine policy, genotype composition,
|
|
/// Farbschlag matching, high-confidence linking, and idempotency.
|
|
/// </summary>
|
|
public class ImportServiceTests : IDisposable
|
|
{
|
|
private readonly string _dir;
|
|
|
|
public ImportServiceTests()
|
|
{
|
|
_dir = Path.Combine(Path.GetTempPath(), "feat8c-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_dir);
|
|
File.WriteAllText(Path.Combine(_dir, "litters.json"), LittersJson);
|
|
File.WriteAllText(Path.Combine(_dir, "animals.json"), AnimalsJson);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_dir, recursive: true); } catch { }
|
|
}
|
|
|
|
private ApplicationContext NewDb()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
|
.UseInMemoryDatabase("feat8c-" + Guid.NewGuid().ToString("N"))
|
|
.Options;
|
|
var db = new ApplicationContext(opts);
|
|
db.Database.EnsureCreated(); // applies the 73-variety HasData seed
|
|
return db;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DryRun_categorises_without_writing()
|
|
{
|
|
using var db = NewDb();
|
|
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: false);
|
|
|
|
Assert.False(report.Executed);
|
|
Assert.Equal(2, report.Litters.InSource);
|
|
Assert.Equal(2, report.Litters.Created);
|
|
// 4 animals: a1 (hoch) + a4 (date-only) loadable; a2 conflict, a3 stub quarantined
|
|
Assert.Equal(4, report.Animals.InSource);
|
|
Assert.Equal(2, report.Animals.Created);
|
|
Assert.Equal(1, report.Animals.LinkedToLitter); // only a1 (hoch)
|
|
Assert.Equal(1, report.Animals.FarbschlagMatched); // a1 -> Agouti
|
|
Assert.Equal(1, report.Animals.Quarantined.Conflicts);
|
|
Assert.Equal(1, report.Animals.Quarantined.Stubs);
|
|
Assert.Equal(1, report.Animals.Quarantined.DateOnlyLinks);
|
|
|
|
// nothing written in dry-run
|
|
Assert.Equal(0, await db.Gerbils.CountAsync());
|
|
Assert.Equal(0, await db.Litters.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_loads_conflict_free_and_links_high_confidence()
|
|
{
|
|
using var db = NewDb();
|
|
var report = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
|
|
|
Assert.True(report.Executed);
|
|
Assert.Equal(2, await db.Litters.CountAsync());
|
|
Assert.Equal(2, await db.Gerbils.CountAsync()); // a1, a4 only
|
|
|
|
// conflict + stub never loaded
|
|
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a2"));
|
|
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "a3"));
|
|
|
|
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
|
Assert.NotNull(a1.LitterId); // high-confidence link
|
|
Assert.Equal("FEAT-8 Stammbaum/Wurfchronik", a1.ImportSource);
|
|
Assert.NotNull(a1.ColorVarietyId); // Agouti matched
|
|
Assert.Contains("aa CC", a1.Genotype); // composed from mapped8locus
|
|
Assert.Contains("??", a1.Genotype!); // missing loci -> ??
|
|
Assert.Contains("eef", a1.Genotype!); // e^f caret stripped
|
|
Assert.Contains("RawGenotype", a1.RawImportData!); // raw verbatim preserved
|
|
|
|
var a4 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a4");
|
|
Assert.Null(a4.LitterId); // date-only link quarantined
|
|
|
|
// PairingCode carried through
|
|
Assert.True(await db.Litters.AnyAsync(l => l.PairingCode == "G01/ZdkC"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_is_idempotent()
|
|
{
|
|
using var db = NewDb();
|
|
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
|
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
|
|
|
Assert.Equal(0, second.Animals.Created);
|
|
Assert.Equal(0, second.Litters.Created);
|
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
|
Assert.Equal(2, await db.Litters.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
|
{
|
|
using var db = NewDb();
|
|
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
|
|
|
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
|
// GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
|
|
Assert.True(a1.IsDeaf);
|
|
// Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
|
|
// (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
|
|
Assert.Contains("Sls", a1.RawImportData!);
|
|
Assert.Contains("WFNZ", a1.RawImportData!);
|
|
// and Sls must NOT leak into the compact 8-locus genotype string
|
|
Assert.DoesNotContain("Sl", a1.Genotype!);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
|
{
|
|
var g = new SourceGenotype
|
|
{
|
|
Mapped8locus = new()
|
|
{
|
|
["A"] = new() { "a", "a" },
|
|
["C"] = new() { "C", "c^chm" },
|
|
["E"] = new() { "e", "e^f" },
|
|
},
|
|
};
|
|
// order A C D E G P Sp Re ; missing -> ??
|
|
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
|
[InlineData("5.3.21", 2021, 3, 5)]
|
|
public void ParseDate_handles_german_dates(string s, int y, int m, int d)
|
|
{
|
|
var date = ImportService.ParseDate(s);
|
|
Assert.Equal(new DateOnly(y, m, d), date);
|
|
}
|
|
|
|
// ---- fixtures ----
|
|
private const string LittersJson = """
|
|
[
|
|
{"id":"L1","litterId":"A","date":"01.02.2020","damName":"Mama [X]","sireName":"Papa of Y","totalBorn":4,"zuchtnummer":"G01/ZdkC","note":"erster Wurf"},
|
|
{"id":"L2","litterId":"B","date":"05.03.2021","damName":"Oma [Z]","sireName":"Opa of W","totalBorn":2,"zuchtnummer":"G02/ZdkC","note":""}
|
|
]
|
|
""";
|
|
|
|
private const string AnimalsJson = """
|
|
[
|
|
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
|
|
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
|
|
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
|
|
"deaf":true,"tags":["WFNZ"],
|
|
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
|
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
|
|
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
|
|
"farbschlag":"Schwarz","farbschlagVariants":["Schwarz"],
|
|
"genotype":{"mapped8locus":{"A":["a","a"]},"rawGenotype":"aa","unmappedTokens":[]},
|
|
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1","f2"],"conflict":true},
|
|
{"id":"a3","name":"Namenlos","dob":"","death":"","gender":null,
|
|
"farbschlag":"","farbschlagVariants":[],
|
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
|
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false},
|
|
{"id":"a4","name":"Unsicher","dob":"05.03.2021","death":"","gender":null,
|
|
"farbschlag":"","farbschlagVariants":[],
|
|
"genotype":{"mapped8locus":{"A":["A","a"]},"rawGenotype":"Aa","unmappedTokens":[]},
|
|
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
|
"litterRef":{"litterId":"L2","method":"geburtsdatum","confidence":"niedrig"}}
|
|
]
|
|
""";
|
|
}
|
|
}
|