Genetik-Engine (bde4ec70, f89e95ad, 2322c2a8):
- Saphir/Platin/Platin-Hell unterscheiden sich NUR in der C-Zygotie und waren im
Katalog identisch (locusToken reduziert auf das dominante Allel) → Saphir war
unerreichbar. Token darf jetzt ein exaktes, ungeordnetes Allelpaar "x/y"
verlangen: Platin C/C, Saphir C/cchm, Platin-Hell C/ch. TS- und Python-Mirror
identisch, Seeds regeneriert, Migration ReseedColorVarietiesGen6Saphir.
- c[hm] wird als Alias auf c[chm] normalisiert (Tippfehler in zwei Charts) —
vorher war Jays Gencode unparsebar und ergab "Zobel-Hell".
- 6 Tiere wechseln den errechneten Farbschlag (5x Platin→Saphir, Jay→Zobel).
Importer-Logik:
- renameTo-Notiz-Sweep: der alte Name blieb in Wurf-Notizen stehen ("Blacky +
Kruke") — jetzt wortgenau ersetzt, mit vier Guards gegen Kollateralschaden.
- Neuer Resolution-Schlüssel goHomeDate (autoritativ, zieht GivenAway nach) und
spottingType; Gencode-Overrides gewinnen jetzt bei präzisem Match (vorher
wurden Entscheidungen stumm verschluckt, z. B. Eliza und Chris).
- parse_date verwirft implausible Jahre (JackJack hatte 1310-05-13).
- extract.py erkennt Stammbaum-Blöcke auch ohne Stern vor dem Geburtsdatum
(Bijou bekam dadurch den Nachbar-Ast als Mutter).
- Backend-Ingest überträgt SpottingType (nur bei Payload-Wert).
Daten (conflict-decisions.json, re-ingest-stabil): Kuke-Merge + Zuchtname,
Jamie- und Sakura-Dubletten, Phantom-Tier "Unbekannt", Eltern von Kathlin,
Fast Boy/Ziwa und dem Q4-/TS-Wurf, Eliza-Gencode, Merle-Abgabe, JackJack.
Frontend (24522f5f): Abgabedatum steht in der Tier-Akte jetzt direkt unter dem
Abnehmer und hängt nicht mehr am Status.
Tests: +25 Checks test_merge_resolve, +7 test_genotype, +8 genetics.test.ts,
neuer Extract- und Ingest-Test, e2e GOHOME-ROW. Alles grün.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
343 lines
15 KiB
C#
343 lines
15 KiB
C#
using GerbilManagerWebAPI.Import;
|
|
using GerbilManagerWebAPI.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
|
|
namespace GerbilManager.Tests
|
|
{
|
|
public class IngestResolvedServiceTests : IDisposable
|
|
{
|
|
private readonly string _dir;
|
|
private readonly string _resolvedJsonPath;
|
|
private Guid ContactId { get; set; }
|
|
private Guid FatherId { get; set; }
|
|
private Guid ContractId { get; set; }
|
|
private Guid UnknownGerbilId { get; set; }
|
|
|
|
public IngestResolvedServiceTests()
|
|
{
|
|
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(_dir);
|
|
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
|
|
|
|
var contactId = Guid.NewGuid();
|
|
var fatherId = Guid.NewGuid();
|
|
var motherId = Guid.NewGuid();
|
|
var litterId = Guid.NewGuid();
|
|
var photoId = Guid.NewGuid();
|
|
ContactId = contactId;
|
|
FatherId = fatherId;
|
|
ContractId = Guid.NewGuid();
|
|
UnknownGerbilId = Guid.NewGuid();
|
|
|
|
var data = new
|
|
{
|
|
Contacts = new[]
|
|
{
|
|
new
|
|
{
|
|
Id = contactId,
|
|
Name = "Test Breeder",
|
|
ZuchtName = "Test Zucht",
|
|
City = "Test City",
|
|
Email = "test@example.com",
|
|
Homepage = "",
|
|
Phone = "",
|
|
Address = "",
|
|
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
|
}
|
|
},
|
|
Litters = new[]
|
|
{
|
|
new
|
|
{
|
|
Id = litterId,
|
|
Name = "Wurf A",
|
|
Date = "2026-01-01",
|
|
TotalBorn = 5,
|
|
DeathsWithin8Weeks = 0,
|
|
FatherId = fatherId,
|
|
MotherId = motherId,
|
|
ExpectedGoHomeDate = (string?)null,
|
|
Notes = "Test litter notes",
|
|
PairingCode = "PC01",
|
|
ExternalRef = "ext-litter-1",
|
|
LitterLetter = "A",
|
|
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
|
}
|
|
},
|
|
Gerbils = new[]
|
|
{
|
|
new
|
|
{
|
|
Id = fatherId,
|
|
Name = "Papa",
|
|
Gender = "male",
|
|
Status = "Breeding",
|
|
LitterId = (Guid?)null,
|
|
OriginContactId = contactId,
|
|
ReceiverContactId = (Guid?)null,
|
|
EnclosureId = (Guid?)null,
|
|
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
|
DateOfBirth = "2025-01-01",
|
|
DateOfDeath = (string?)null,
|
|
CauseOfDeath = (string?)null,
|
|
GoHomeDate = (string?)null,
|
|
Genotype = "aa CC DD EE GG PP spsp rere",
|
|
Notes = "",
|
|
ImportSource = "docx-export",
|
|
ExternalRef = "ext-papa",
|
|
RawImportData = "{}",
|
|
OriginBreeder = "Test Zucht",
|
|
NameSearch = "papa",
|
|
CharacterTraits = new string[] {},
|
|
CharacterNote = (string?)null,
|
|
IsDeaf = false,
|
|
IsResident = true,
|
|
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
|
},
|
|
new
|
|
{
|
|
Id = motherId,
|
|
Name = "Mama",
|
|
Gender = "female",
|
|
Status = "Breeding",
|
|
LitterId = (Guid?)null,
|
|
OriginContactId = contactId,
|
|
ReceiverContactId = (Guid?)null,
|
|
EnclosureId = (Guid?)null,
|
|
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
|
|
DateOfBirth = "2025-01-01",
|
|
DateOfDeath = (string?)null,
|
|
CauseOfDeath = (string?)null,
|
|
GoHomeDate = (string?)null,
|
|
Genotype = "aa CC DD EE GG PP spsp rere",
|
|
Notes = "",
|
|
ImportSource = "docx-export",
|
|
ExternalRef = "ext-mama",
|
|
RawImportData = "{}",
|
|
OriginBreeder = "Test Zucht",
|
|
NameSearch = "mama",
|
|
CharacterTraits = new string[] {},
|
|
CharacterNote = (string?)null,
|
|
IsDeaf = false,
|
|
IsResident = true,
|
|
Provenance = (string?)null
|
|
}
|
|
},
|
|
GerbilPhotos = new[]
|
|
{
|
|
new
|
|
{
|
|
Id = photoId,
|
|
GerbilId = fatherId,
|
|
FileName = "photo1.jpg",
|
|
SortOrder = 0
|
|
}
|
|
},
|
|
SaleContracts = new[]
|
|
{
|
|
new
|
|
{
|
|
Id = ContractId,
|
|
ContactId = contactId,
|
|
Price = 35.0m,
|
|
HandoverDate = "2023-09-29",
|
|
ContractDate = "2023-09-29",
|
|
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
|
|
// One known animal + one unknown id (must be skipped defensively).
|
|
Animals = new[] { fatherId, UnknownGerbilId }
|
|
}
|
|
}
|
|
};
|
|
|
|
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_dir, recursive: true); } catch { }
|
|
}
|
|
|
|
private ApplicationContext NewDb()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
|
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
|
|
.Options;
|
|
var db = new ApplicationContext(opts);
|
|
db.Database.EnsureCreated();
|
|
return db;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestResolved_loads_data_and_resolves_all_relations()
|
|
{
|
|
using var db = NewDb();
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
{ "Import:SourcePath", _dir }
|
|
})
|
|
.Build();
|
|
|
|
var service = new IngestResolvedService(db, config, null!);
|
|
var result = await service.RunAsync();
|
|
|
|
Assert.Contains("Ingestion successful!", result);
|
|
|
|
// Verify counts
|
|
Assert.Equal(1, await db.Contacts.CountAsync());
|
|
Assert.Equal(1, await db.Litters.CountAsync());
|
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
|
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
|
|
|
|
// Verify relations
|
|
var litter = await db.Litters.SingleAsync();
|
|
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
|
|
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
|
|
var photo = await db.GerbilPhotos.SingleAsync();
|
|
|
|
Assert.Equal(father.Id, litter.FatherId);
|
|
Assert.Equal(mother.Id, litter.MotherId);
|
|
Assert.Equal(father.Id, photo.GerbilId);
|
|
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
|
|
|
// Provenance round-trips verbatim through ingest (and stays null when absent).
|
|
Assert.NotNull(father.Provenance);
|
|
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
|
Assert.Contains("mergedRecordCount", father.Provenance);
|
|
// The chronological, file-attributed history survives ingest verbatim
|
|
// (stored as opaque JSON on the text column — no schema for it).
|
|
Assert.Contains("history", father.Provenance);
|
|
Assert.Contains("Genotyp aus", father.Provenance);
|
|
Assert.Null(mother.Provenance);
|
|
|
|
// Contact + litter provenance also round-trips through the ingest.
|
|
var contact = await db.Contacts.SingleAsync();
|
|
Assert.NotNull(contact.Provenance);
|
|
Assert.Contains("als Züchter erkannt", contact.Provenance);
|
|
|
|
Assert.NotNull(litter.Provenance);
|
|
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
|
Assert.Contains("fromWurfchronik", litter.Provenance);
|
|
|
|
// SaleContracts are created from the payload, and animal links to
|
|
// unknown gerbils are skipped defensively (only the known father links).
|
|
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
|
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
|
Assert.Equal(ContractId, contract.Id);
|
|
Assert.Equal(ContactId, contract.ContactId);
|
|
Assert.Equal(35.0m, contract.Price);
|
|
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
|
|
var link = Assert.Single(contract.Animals);
|
|
Assert.Equal(FatherId, link.GerbilId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
|
|
{
|
|
using var db = NewDb();
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
{ "Import:SourcePath", _dir }
|
|
})
|
|
.Build();
|
|
|
|
var service = new IngestResolvedService(db, config, null!);
|
|
await service.RunAsync();
|
|
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
|
|
|
// Re-ingest wipes then recreates from the (deterministic) payload —
|
|
// the contract survives by being part of the payload, no duplicate PK.
|
|
await service.RunAsync();
|
|
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
|
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
|
Assert.Equal(ContractId, contract.Id);
|
|
Assert.Single(contract.Animals);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestResolved_rederives_status_after_deceased_override()
|
|
{
|
|
// Ticket 37ab228a "Gaida": ein verstorbenes Tier blieb nach dem Ingest auf Status
|
|
// 'Breeding' hängen, weil der Freeze das Todesdatum aus dem Override zurückschrieb,
|
|
// ohne den Status neu abzuleiten. Der Ingest muss GerbilStatusService.Apply erneut aufrufen.
|
|
using var db = NewDb();
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
|
.Build();
|
|
|
|
db.GerbilOverrides.Add(new GerbilOverride
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
GerbilId = FatherId,
|
|
EntityName = "Papa",
|
|
IsVerified = false, // "geschützt" — wird trotzdem angewandt
|
|
OverrideJson = "{\"dateOfDeath\":\"2025-10-09\"}",
|
|
UpdatedAt = DateTimeOffset.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
|
|
|
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
|
Assert.NotNull(father.DateOfDeath); // Todesdatum aus Override angewandt
|
|
Assert.Equal(GerbilStatus.Deceased, father.Status); // ... und Status daraus abgeleitet
|
|
}
|
|
|
|
/// <summary>
|
|
/// Setzt bzw. entfernt die Scheckungsart im gestageten Payload (Testdaten werden hier
|
|
/// selbst aufgebaut — der Test hängt bewusst an keiner echten conflict-decisions.json).
|
|
/// </summary>
|
|
private void SetPayloadSpottingType(string? value, bool remove = false)
|
|
{
|
|
var root = JsonNode.Parse(File.ReadAllText(_resolvedJsonPath))!;
|
|
var papa = root["Gerbils"]!.AsArray()
|
|
.First(g => (string?)g!["Name"] == "Papa")!.AsObject();
|
|
if (remove) papa.Remove("SpottingType");
|
|
else papa["SpottingType"] = value;
|
|
File.WriteAllText(_resolvedJsonPath, root.ToJsonString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IngestResolved_writes_spotting_type_and_keeps_it_when_payload_has_none()
|
|
{
|
|
// Ticket a547be62 (Eliza): Die Scheckungsart kennt der Importer nur über
|
|
// conflict-decisions.json — sie MUSS also durch den Ingest getragen werden.
|
|
// Umgekehrt darf ein leerer Payload-Wert eine von Hand in der App erfasste
|
|
// Scheckungsart NICHT nullen (der Importer weiß von ihr nichts).
|
|
using var db = NewDb();
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
|
.Build();
|
|
|
|
// (1) Neues Tier: Wert aus dem Payload landet in der DB.
|
|
SetPayloadSpottingType("Ansatzschecke");
|
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
|
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
|
Assert.Equal("Ansatzschecke", father.SpottingType);
|
|
|
|
// (2) Re-Ingest, Feld fehlt im Payload → bestehender Wert bleibt stehen.
|
|
SetPayloadSpottingType(null, remove: true);
|
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
|
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
|
Assert.Equal("Ansatzschecke", father.SpottingType);
|
|
|
|
// (3) Re-Ingest mit explizitem null → ebenfalls kein Nullen (Guard im Update-Zweig).
|
|
SetPayloadSpottingType(null);
|
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
|
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
|
Assert.Equal("Ansatzschecke", father.SpottingType);
|
|
|
|
// (4) Ein NEUER Wert aus dem Payload gewinnt weiterhin (Entscheidung korrigiert).
|
|
SetPayloadSpottingType("Mantelschecke");
|
|
await new IngestResolvedService(db, config, null!).RunAsync();
|
|
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
|
Assert.Equal("Mantelschecke", father.SpottingType);
|
|
}
|
|
}
|
|
}
|