PEDIGREE-LINK: chart parentRefs→litters, box-colour=sex, name-bleed fix

Structural fix (god, Julian-reported via 'C'): the loader ignored
SourceAnimal.ParentRefs, so animals whose ancestry exists only as
Stammbaum chart-position refs loaded with LitterId=null ("unbekannt").
ImportService now synthesizes/reuses a derived litter from parentRefs:
resolves father+mother via name+DOB, groups siblings (same parents+dob)
into one litter, sets Father/Mother + offspring LitterId, dates it to the
offspring DOB, and tags Notes "aus Stammbaum-Diagramm abgeleitet
(Konfidenz: …)" so it's transparent/reversible. Existing animals that
become linkable are re-linked on re-run (sweep-idempotent). Dry-run counts
included. Projected: ~124 loadable animals gain a parent link.

Box-colour = sex (Julian): blue box = male, white box = female. All 11
pedigrees encode this as a solid theme-8 (accent5/blue) fill vs no fill.
xlsx_util.cell_fill_sex reads it; extract.py sets animal.gender from the
box; ImportService.InferGender prefers it over sire/dam name inference.
Result: 306/306 loadable animals now sexed (154♂/152♀).

Extractor noise fix (god): reject Farbschlag values that are actually a
parent NAME bled across cells (contain v.d./von/of/gen.) — cleared phantom
conflicts (e.g. Chayton). Combined with GEN-3 Uw→G: Konflikte 32→21.
Also skip Excel "~$" lock files in the glob.

GEN-3a contract (Kevin): ComposeGenotype appends "Slsl" for WP/Sls
carriers (wild-type sl/sl omitted) so 8-locus strings stay unchanged.

Importer-only. The live re-import into Julian's DB stays a separate
supervised gated step. 95 C# tests + python genotype tests green;
has-pending-model-changes clean (no schema change on this branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:48:30 +02:00
parent a57b481a7e
commit ea79703dbf
6 changed files with 304 additions and 58 deletions

View File

@@ -115,8 +115,67 @@ namespace GerbilManager.Tests
// (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!);
// GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
Assert.EndsWith("Slsl", a1.Genotype!);
}
[Fact]
public void ComposeGenotype_appends_Slsl_only_for_carriers()
{
// wild-type sl/sl is omitted -> plain 8-locus string
var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
// WP heterozygote -> trailing Slsl
var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
}
[Fact]
public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
{
// Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
// 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
// litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
]}
]
""");
using var db = NewDb();
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(1, report.Animals.ParentLinksFromChart);
Assert.Equal(1, report.Litters.DerivedFromChart);
var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
Assert.NotNull(c.LitterId); // C no longer "unbekannt"
// box-colour sex flows through (blue=male, white=female)
Assert.Equal(Gender.male, papa.Gender);
Assert.Equal(Gender.female, mama.Gender);
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
Assert.Equal(papa.Id, litter.FatherId);
Assert.Equal(mama.Id, litter.MotherId);
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]