Files
GerbilManager/GerbilManager.Tests/Rpro3ImportTests.cs
Gulum c7b054e909 feat(import): RPRO3-Importer um 6 Feature-Tabellen erweitern
becken→Gehege-Reinigung, herktier→Erwerb, abgeben/abstat→Reservierung,
getback→Rücknahmen, nachfrage→Warteliste, ausz→Ausstellungen.
Deterministische GUIDs, idempotenter Re-Import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:05:01 +02:00

338 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using GerbilManagerWebAPI.Import.Rpro3;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests
{
/// <summary>
/// Tests für den RennmausPro-III-Importer (Reader + Dedup + Execute).
/// Die meisten Tests bauen eine hermetische Mini-SQLite im RPRO3-Schema; ein optionaler
/// Test gegen die echte entpackte DB läuft nur, wenn sie lokal vorhanden ist.
/// </summary>
public class Rpro3ImportTests
{
static Rpro3ImportTests()
{
// CP1252 für die Tests verfügbar machen (die Produktion registriert es im Reader-Ctor).
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
}
// ── Unit: Decode / JDN / Genotyp ──────────────────────────────
[Fact]
public void JdnToDate_converts_known_julian_day_numbers()
{
// Blacky _BIRTH = 2455036.0 → 2009-07-23 (siehe rpro3-vergleich.md).
Assert.Equal(new DateOnly(2009, 7, 23), Rpro3Reader.JdnToDate(2455036.0));
Assert.Null(Rpro3Reader.JdnToDate(0));
Assert.Null(Rpro3Reader.JdnToDate(null));
}
[Fact]
public void SmartDecode_prefers_utf8_then_cp1252()
{
var utf8 = System.Text.Encoding.UTF8.GetBytes("männlich");
Assert.Equal("männlich", Rpro3Reader.SmartDecode(utf8));
var cp1252 = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
// CP1252-Bytes sind kein gültiges UTF-8 → Fallback dekodiert korrekt.
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
Assert.Equal("männlich", Rpro3Reader.SmartDecode(cp1252));
}
[Fact]
public void CleanGenotype_strips_brackets_keeps_dash()
{
Assert.Equal("aa Ccchm DD EE GG Pp spsp", Rpro3Reader_CleanGenotype("aa Cc[chm] DD EE GG Pp spsp"));
Assert.Equal("Kim AA C- Dd", Rpro3Reader_CleanGenotype("Kim AA C- Dd"));
}
private static string? Rpro3Reader_CleanGenotype(string s)
{
// CleanGenotype ist internal in Rpro3ImportService → über InternalsVisibleTo erreichbar.
var m = typeof(Rpro3ImportService).GetMethod("CleanGenotype",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (string?)m!.Invoke(null, new object?[] { s });
}
// ── Unit: Dedup ───────────────────────────────────────────────
[Fact]
public void Dedup_merges_compatible_same_name_records()
{
var animals = new List<Rpro3Animal>
{
Ext("u1", "Alice", new DateOnly(2010, 7, 19), "Schimmel", "Clan A"),
Ext("u2", "Alice", new DateOnly(2010, 7, 19), "Schimmel", ""), // kompatibel → merge
Ext("u3", "Alice", null, "Schimmel", "Clan A"), // kompatibel → merge
Ext("u4", "Alice", new DateOnly(2001, 1, 1), "Schwarz", "Clan B"), // Konflikt → eigener Cluster
};
var r = Rpro3Dedup.Run(animals);
// Mind. ein Merge-Cluster (u1/u2/u3), u4 separat → Name "Alice" mehrdeutig.
Assert.Single(r.MergeClusters);
Assert.Equal(3, r.MergeClusters.Values.First().Count);
Assert.Contains(r.Ambiguous, a => a.Name == "Alice");
}
[Fact]
public void Dedup_does_not_merge_placeholder_names()
{
var animals = new List<Rpro3Animal>
{
Ext("u1", "unbekannt", null, "", ""),
Ext("u2", "unbekannt", null, "", ""),
};
var r = Rpro3Dedup.Run(animals);
Assert.Empty(r.MergeClusters);
Assert.Equal(2, r.Placeholders.Count);
}
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
[Fact]
public async Task Execute_imports_animals_litters_contacts_and_is_idempotent()
{
var dbPath = BuildMiniRpro3();
try
{
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
Assert.Equal(2, data.Animals.Count(a => a.Src == Rpro3Src.Stamm));
Assert.True(data.Animals.Count(a => a.Src == Rpro3Src.Fremd) >= 2);
using var efConn = new SqliteConnection("DataSource=:memory:");
efConn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
using var ctx = new ApplicationContext(opts);
ctx.Database.EnsureCreated();
var config = new ConfigurationBuilder().Build();
var service = new Rpro3ImportService(ctx, config, null);
var analyze = await service.AnalyzeAsync(data, photosProvided: false, availablePhotoFiles: null);
Assert.Equal(2, analyze.Counts.OwnAnimals);
Assert.True(analyze.NewVsCurrentNew >= 2);
var first = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.True(first.GerbilsImported >= 4); // 2 stamm + ≥2 fremd
Assert.Equal(1, first.LittersImported >= 1 ? 1 : 0); // mind. 1 Wurf importiert
var gerbilCount1 = await ctx.Gerbils.CountAsync();
var litterCount1 = await ctx.Litters.CountAsync();
// Re-Run: idempotent — gleiche Zeilenzahl, keine Dubletten.
var second = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.Equal(gerbilCount1, await ctx.Gerbils.CountAsync());
Assert.Equal(litterCount1, await ctx.Litters.CountAsync());
// Pedigree: Wurf hat Mutter + Vater verknüpft (resolved gegen importierte Tiere).
var litter = await ctx.Litters.FirstAsync(l => l.Name!.StartsWith("Wurf"));
Assert.NotNull(litter.MotherId);
Assert.NotNull(litter.FatherId);
// Genotyp wurde aus _FCODE übernommen + Klammern entfernt.
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
Assert.Equal("aa Ccchm DD EE GG Pp spsp", blacky.Genotype);
Assert.True(blacky.IsResident);
// Encoding: "männlich" korrekt dekodiert (Latin-1-Quelle).
Assert.Equal(Gender.male, blacky.Gender);
}
finally { try { File.Delete(dbPath); } catch { } }
}
// ── Integration: Feature-Tabellen (Becken/Erwerb/Reservierung/Rücknahme/Warteliste/Ausstellung) ──
[Fact]
public async Task Execute_imports_feature_tables_and_is_idempotent()
{
var dbPath = BuildMiniRpro3();
try
{
var data = new Rpro3Reader().ReadFromDbFile(dbPath);
// Reader hat alle Feature-Tabellen erfasst.
Assert.Equal(2, data.Becken.Count);
Assert.Single(data.Acquisitions);
Assert.Single(data.Abgeben);
Assert.Single(data.Abstat);
Assert.Single(data.Getback);
Assert.Single(data.Nachfrage);
Assert.Single(data.Ausstellungen);
Assert.Equal(1, data.StammBecken["1"]); // Blacky → Becken 1
using var efConn = new SqliteConnection("DataSource=:memory:");
efConn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(efConn).Options;
using var ctx = new ApplicationContext(opts);
ctx.Database.EnsureCreated();
var service = new Rpro3ImportService(ctx, new ConfigurationBuilder().Build(), null);
var res = await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.Equal(2, res.EnclosuresImported);
Assert.Equal(1, res.AcquisitionsImported);
Assert.Equal(2, res.ReservationsImported); // abgeben(Blacky, reserviert) + abstat(Kuke, abgegeben)
Assert.Equal(1, res.ReturnsImported);
Assert.Equal(1, res.WaitingListImported);
Assert.Equal(1, res.ExhibitionsImported);
// Becken → Gehege; Blacky ist Becken 1 zugeordnet.
Assert.Equal(2, await ctx.Enclosures.CountAsync());
var kaefig = await ctx.Enclosures.FirstAsync(e => e.Name == "Käfig A");
Assert.Equal("80x35x40", kaefig.Size);
Assert.Equal(3, kaefig.Capacity);
Assert.Equal(60, kaefig.CleaningCycleDays);
var blacky = await ctx.Gerbils.FirstAsync(g => g.Name == "Blacky");
Assert.Equal(kaefig.Id, blacky.EnclosureId);
// Erwerb verknüpft auf Blacky mit Preis/Datum.
var acq = await ctx.AcquisitionRecords.SingleAsync();
Assert.Equal(blacky.Id, acq.GerbilId);
Assert.Equal(9.5m, acq.Price);
Assert.NotNull(acq.Date);
// Reservierung: Blacky reserviert für Lisa M.
var resv = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == blacky.Id);
Assert.Equal("reserviert", resv.Status);
Assert.Equal("Lisa M.", resv.ContactName);
// Abgabe-Abschluss: Kuke → abgegeben.
var kuke = await ctx.Gerbils.FirstAsync(g => g.Name == "Kuke");
var kukeRes = await ctx.SaleReservations.FirstAsync(r => r.GerbilId == kuke.Id);
Assert.Equal("abgegeben", kukeRes.Status);
Assert.Equal(15.0m, kukeRes.Price);
// Rücknahme: Blacky kam zurück.
var ret = await ctx.ReturnRecords.SingleAsync();
Assert.Equal(blacky.Id, ret.GerbilId);
Assert.Equal(15.0m, ret.OriginalPrice);
Assert.Equal("Lisa M.", ret.FromContactName);
// Warteliste: männlicher Schimmel.
var wl = await ctx.WaitingListEntries.SingleAsync();
Assert.Equal("male", wl.WishGender);
Assert.Equal("Schimmel", wl.WishColor);
Assert.Equal("offen", wl.Status);
// Ausstellung: Blacky, 1. Platz, BOB.
var exh = await ctx.ExhibitionResults.SingleAsync();
Assert.Equal(blacky.Id, exh.GerbilId);
Assert.Equal("Schau 2011", exh.EventName);
Assert.Equal("1. Platz", exh.Placement);
Assert.Equal("BOB", exh.Award);
// Re-Run: idempotent — keine Dubletten in den Feature-Tabellen.
await service.ExecuteAsync(data, Path.GetTempPath(), photosProvided: false, photoSourcePaths: null);
Assert.Equal(2, await ctx.Enclosures.CountAsync());
Assert.Equal(1, await ctx.AcquisitionRecords.CountAsync());
Assert.Equal(2, await ctx.SaleReservations.CountAsync()); // Blacky + Kuke
Assert.Equal(1, await ctx.ReturnRecords.CountAsync());
Assert.Equal(1, await ctx.WaitingListEntries.CountAsync());
Assert.Equal(1, await ctx.ExhibitionResults.CountAsync());
}
finally { try { File.Delete(dbPath); } catch { } }
}
// ── Helpers ──
private static Rpro3Animal Ext(string rid, string name, DateOnly? dob, string farbe, string origin) =>
new() { Rid = rid, Src = Rpro3Src.Fremd, Name = name, Dob = dob, Farbe = farbe, Origin = origin };
/// <summary>Baut eine minimale SQLite im RPRO3-Schema (Latin-1-Geschlecht, JDN-Daten).</summary>
private static string BuildMiniRpro3()
{
var path = Path.Combine(Path.GetTempPath(), "mini-rpro3-" + Guid.NewGuid().ToString("N") + ".db");
using var conn = new SqliteConnection($"DataSource={path}");
conn.Open();
void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); }
Exec("CREATE TABLE herk_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
Exec("CREATE TABLE abn_tb (id INTEGER, _BEZ, _ANREDE, _VNAME, _NNAME, _CLAN, _STR, _ORT, _PLZ, _TELP, _TELD, _MAIL, _HTTP, _MEMO, _BEM)");
Exec("CREATE TABLE baum_tb (id, _MID, _PID)");
Exec("CREATE TABLE stamm_tb (id INTEGER, _NAME, _SEX, _BIRTH, _HERKUNFT, _ZB, _STATUS, _FEHLER, _KASTRAT_DATE, _BECKEN)");
Exec("CREATE TABLE fremd_tb (id INTEGER, _NAME, _SEX, _BIRTH, _ZB, _HERK, _MID, _PID, _TODAM, _KASTRAT_DATE)");
Exec("CREATE TABLE wurf_tb (id INTEGER, _MID, _PID, _AM, _BEZ, _BEM)");
Exec("CREATE TABLE wurftier_tb (id, _WID, _NAME, _SEX, _BIRTH, _SID, _TODAM, _WARUM, _ABAM, _ABN, _PRICE, _ZB, _FEHLER)");
Exec("CREATE TABLE color_tb (id, _FARBE, _FCODE)");
Exec("CREATE TABLE wurfcolor_tb (id, _FARBE, _FCODE)");
Exec("CREATE TABLE fremdcolor_tb (id, _FARBE, _FCODE)");
Exec("CREATE TABLE tod_tb (tid, _AM, _WARUM)");
Exec("CREATE TABLE waage_tb (_TID, _GRAMM, _DATE, _BEM)");
Exec("CREATE TABLE jungwaage_tb (_TID, _WID, _GRAMM, _DATE, _BEM)");
Exec("CREATE TABLE krank_tb (_TID, _DATE, _BEM, _MED)");
Exec("CREATE TABLE diary_tb (_TID, _BEZ, _DATE, _DESC)");
Exec("CREATE TABLE photo_tb (id, _P1, _P2, _P3)");
// Feature-Tabellen.
Exec("CREATE TABLE becken_tb (id INTEGER, _BEZ, _SIZE, _MENGE, _CLEANED, _CYCLUS, _NEXT)");
Exec("CREATE TABLE herktier_tb (id INTEGER, _TID, _DATE, _PRICE, _BEM, _ART)");
Exec("CREATE TABLE abgeben_tb (tid, _RES, _BEM, _TERMIN, _ABN, _BEZ)");
Exec("CREATE TABLE abstat_tb (tid, _IDABN, _AM, _PRICE, _ART, _BEM)");
Exec("CREATE TABLE getback_tb (id INTEGER, _TID, _ZAM, _ZPREIS, _AM, _PREIS, _ABN, _BEM)");
Exec("CREATE TABLE nachfrage_tb (id INTEGER, _COLOR, _SEX, _DATE, _ABN, _CODE, _VOM, _STATUS, _ART)");
Exec("CREATE TABLE ausz_tb (id INTEGER, _TID, _VERANSTALTUNG, _DATE, _ORT, _PLATZ, _AUSZ, _EXTRA, _JUROR, _BEM)");
// Kontakte
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (1,'eigene Zucht','','','')");
Exec("INSERT INTO herk_tb (id,_BEZ,_CLAN,_ORT,_MAIL) VALUES (61,'M.Knoss GG','Clan X','Gießen','a@b.de')");
// Geschlecht als Latin-1-Bytes einfügen (BLOB), um den smart_decode-Fallback zu testen.
var maennBytes = System.Text.Encoding.GetEncoding(1252).GetBytes("männlich");
var weibBytes = System.Text.Encoding.UTF8.GetBytes("weiblich");
void InsertStamm(int id, string name, byte[] sex, double birth, int herk, string zb, int becken = 0)
{
using var c = conn.CreateCommand();
c.CommandText = "INSERT INTO stamm_tb (id,_NAME,_SEX,_BIRTH,_HERKUNFT,_ZB,_STATUS,_FEHLER,_BECKEN) VALUES ($id,$n,$s,$b,$h,$z,3,'',$bk)";
c.Parameters.AddWithValue("$id", id);
c.Parameters.AddWithValue("$n", name);
c.Parameters.AddWithValue("$s", sex);
c.Parameters.AddWithValue("$b", birth);
c.Parameters.AddWithValue("$h", herk);
c.Parameters.AddWithValue("$z", zb);
c.Parameters.AddWithValue("$bk", becken);
c.ExecuteNonQuery();
}
InsertStamm(1, "Blacky", maennBytes, 2455036.0, 61, "ZdKC-B.09.2009", becken: 1);
InsertStamm(2, "Kuke", weibBytes, 2455093.0, 61, "ZdKC-B.10.2009");
// Genotyp (Bracket-Notation) für Blacky.
Exec("INSERT INTO color_tb (id,_FARBE,_FCODE) VALUES ('1','Schwarz','aa Cc[chm] DD EE GG Pp spsp')");
// Externe Ahnen (Eltern von Blacky & Kuke via baum_tb).
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (1,'Oma','weiblich',0,1,'','')");
Exec("INSERT INTO fremd_tb (id,_NAME,_SEX,_BIRTH,_HERK,_MID,_PID) VALUES (2,'Opa','männlich',0,1,'','')");
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('1','u1','u2')"); // Blacky Eltern = Oma×Opa
Exec("INSERT INTO baum_tb (id,_MID,_PID) VALUES ('2','u1','u2')"); // Kuke Eltern = Oma×Opa
// Wurf: Mutter Kuke (2) × Vater Blacky (1)
Exec("INSERT INTO wurf_tb (id,_MID,_PID,_AM,_BEZ,_BEM) VALUES (1,'2','1',2455246.0,'Wurf A','Erster Wurf')");
Exec("INSERT INTO wurftier_tb (id,_WID,_NAME,_SEX,_BIRTH,_SID,_TODAM,_ABAM,_ABN,_PRICE,_ZB) VALUES ('1j1','1','Alvin','männlich',2455246.0,'','','',NULL,'','21 A')");
// Gewicht + Krankheit für Blacky.
Exec("INSERT INTO waage_tb (_TID,_GRAMM,_DATE,_BEM) VALUES ('1',55.0,2455083.0,'')");
Exec("INSERT INTO krank_tb (_TID,_DATE,_BEM,_MED) VALUES ('1',2455077.0,'Prellung','')");
// Feature-Tabellen-Daten.
// Abnehmer-Kontakt (für Reservierung/Rücknahme/Warteliste).
Exec("INSERT INTO abn_tb (id,_BEZ,_VNAME,_NNAME) VALUES (5,'Lisa M.','Lisa','Müller')");
// Becken 1 (Blacky wohnt dort, _BECKEN=1), Becken 2 ohne Bewohner.
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (1,'Käfig A','80x35x40',3,2455595.0,60,2455655.0)");
Exec("INSERT INTO becken_tb (id,_BEZ,_SIZE,_MENGE,_CLEANED,_CYCLUS,_NEXT) VALUES (2,'Terrarium','55x48x110',32,2455638.0,90,2455728.0)");
// Erwerb: Blacky (stamm 1) am JDN für 9.50.
Exec("INSERT INTO herktier_tb (id,_TID,_DATE,_PRICE,_BEM) VALUES (61,'1',2455076.0,9.5,'gekauft')");
// Reservierung: Blacky (stamm 1) reserviert für abn 5.
Exec("INSERT INTO abgeben_tb (tid,_RES,_BEM,_TERMIN,_ABN,_BEZ) VALUES ('1',1,'',2455730.0,5,0)");
// Abgabe-Abschluss: Kuke (stamm 2) abgegeben an abn 5 für 15.
Exec("INSERT INTO abstat_tb (tid,_IDABN,_AM,_PRICE,_BEM) VALUES ('2',5,2455716.0,15.0,'Zog aus.')");
// Rücknahme: Blacky kam zurück von abn 5.
Exec("INSERT INTO getback_tb (id,_TID,_ZAM,_ZPREIS,_AM,_PREIS,_ABN,_BEM) VALUES (2,'1',2455992.0,0.0,2455798.0,15.0,5,'kam zurück')");
// Warteliste: Wunsch nach männlichem Schimmel von abn 5.
Exec("INSERT INTO nachfrage_tb (id,_COLOR,_SEX,_DATE,_ABN,_CODE,_VOM,_STATUS) VALUES (1,'Schimmel','männlich',0.0,5,'egal',2456172.0,'')");
// Ausstellung: ein Ergebnis für Blacky (in echten Backups oft leer).
Exec("INSERT INTO ausz_tb (id,_TID,_VERANSTALTUNG,_DATE,_ORT,_PLATZ,_AUSZ,_JUROR,_BEM) VALUES (1,'1','Schau 2011',2455700.0,'Halle A','1. Platz','BOB','Frau X','schön')");
conn.Close();
SqliteConnection.ClearAllPools();
return path;
}
}
}