From b7ee48401a6b0080f76a33d4c337f64d298f14c2 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:36:29 +0200 Subject: [PATCH] FEAT-8c: loader tests (in-memory DB + fixture) + public test helpers 6 tests: dry-run categorisation (no writes), execute loads conflict-free + links high-confidence + quarantines conflict/stub, idempotency, ComposeGenotype (caret strip + ?? fill), ParseDate. Added EF InMemory to the test project. --- .../GerbilManager.Tests.csproj | 1 + GerbilManager.Tests/ImportServiceTests.cs | 161 ++++++++++++++++++ GerbilManagerWebAPI/Import/ImportService.cs | 20 ++- 3 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 GerbilManager.Tests/ImportServiceTests.cs diff --git a/GerbilManager.Tests/GerbilManager.Tests.csproj b/GerbilManager.Tests/GerbilManager.Tests.csproj index d59000b..17f8e2e 100644 --- a/GerbilManager.Tests/GerbilManager.Tests.csproj +++ b/GerbilManager.Tests/GerbilManager.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/GerbilManager.Tests/ImportServiceTests.cs b/GerbilManager.Tests/ImportServiceTests.cs new file mode 100644 index 0000000..5be8ea1 --- /dev/null +++ b/GerbilManager.Tests/ImportServiceTests.cs @@ -0,0 +1,161 @@ +using GerbilManagerWebAPI.Import; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManager.Tests +{ + /// + /// 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. + /// + 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() + .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 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"]},"rawGenotype":"aa CC D- ee[f]","unmappedTokens":[]}, + "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"}} + ] + """; + } +} diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs index a254009..6cfad3c 100644 --- a/GerbilManagerWebAPI/Import/ImportService.cs +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -32,12 +32,20 @@ namespace GerbilManagerWebAPI.Import private readonly string _sourceDir; private readonly string _photoRoot; - public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env) + /// Test-friendly constructor with explicit paths. + public ImportService(ApplicationContext db, string sourceDir, string photoRoot) { _db = db; - _sourceDir = config["Import:SourcePath"] - ?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")); - _photoRoot = config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"); + _sourceDir = sourceDir; + _photoRoot = photoRoot; + } + + public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env) + : this(db, + config["Import:SourcePath"] + ?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")), + config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage")) + { } public async Task RunAsync(bool execute) @@ -241,7 +249,7 @@ namespace GerbilManagerWebAPI.Import return JsonSerializer.Deserialize(fs, Json); } - internal static string ComposeGenotype(SourceGenotype g) + public static string ComposeGenotype(SourceGenotype g) { var tokens = LocusOrder.Select(locus => { @@ -263,7 +271,7 @@ namespace GerbilManagerWebAPI.Import return Gender.unknown; } - internal static DateOnly? ParseDate(string s) + public static DateOnly? ParseDate(string s) { if (string.IsNullOrWhiteSpace(s)) return null; var m = Regex.Match(s, @"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");