Compare commits
5 Commits
db85a6e0dc
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| efce79b3fa | |||
| 13eb17b453 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| 0c94cfcbf1 |
@@ -321,6 +321,192 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||
{
|
||||
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
|
||||
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: Vater is in conflict -> not loaded
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||
|
||||
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
|
||||
var animals2 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
|
||||
{
|
||||
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
|
||||
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
|
||||
//
|
||||
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
|
||||
// Run 2: sire loaded (new animal).
|
||||
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
|
||||
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
|
||||
// Backfill must use allDbNormToGid to find him.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: sire quarantined
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
|
||||
|
||||
// Run 2: sire now loaded
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
|
||||
// Run 2 itself may or may not backfill (depends on name normalization alignment).
|
||||
// For the test we care about run 3.
|
||||
|
||||
// Run 3: sire NOT in animals.json at all (absent from new extract).
|
||||
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
|
||||
// by manually resetting FatherId to null so run 3 must fix it.
|
||||
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
litter3.FatherId = null;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
|
||||
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
|
||||
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||
{
|
||||
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
|
||||
|
||||
// Run 2 dry-run with papi un-quarantined
|
||||
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
|
||||
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||
Assert.Null(litter.FatherId); // not written in dry-run
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||
[InlineData("5.3.21", 2021, 3, 5)]
|
||||
|
||||
@@ -86,7 +86,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||
int ParentFksBackfilled = 0);
|
||||
|
||||
public sealed record AnimalSummary(
|
||||
int InSource,
|
||||
|
||||
@@ -399,10 +399,67 @@ namespace GerbilManagerWebAPI.Import
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// PARENT-FK BACKFILL (idempotent re-run): already-imported Wurfchronik litters that
|
||||
// have null Father/MotherId because the parent was previously quarantined may now be
|
||||
// resolvable. Two lookup sources — must check BOTH:
|
||||
// (a) createdAnimalByName: animals loaded/re-linked in THIS run (new or existing).
|
||||
// (b) allDbNormToGid: ALL gerbils already in the DB, for parents loaded in an
|
||||
// EARLIER run who are no longer in the current extract (e.g. alreadyImported
|
||||
// animals absent from this run's animals.json, or name normalization mismatch
|
||||
// between animals.json and the Wurfchronik sire/dam field).
|
||||
// Counted for dry-run too; writes only when execute=true.
|
||||
int parentFksBackfilled = 0;
|
||||
{
|
||||
// Build DB-wide normalized-name lookup (supplementary to createdAnimalByName).
|
||||
var allDbNormToGid = existingRows
|
||||
.GroupBy(g => Normalize(StripZucht(g.Name)))
|
||||
.ToDictionary(grp => grp.Key, grp => grp.First().Id);
|
||||
|
||||
var existingWithNullParent = await _db.Litters
|
||||
.Where(l => l.FatherId == null || l.MotherId == null)
|
||||
.Select(l => new { l.Id, l.Name, l.FatherId, l.MotherId })
|
||||
.ToListAsync();
|
||||
var sourceByName = litters
|
||||
.GroupBy(sl => $"Wurf {sl.LitterId}".Trim())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
Guid? ResolveParentForBackfill(string rawName)
|
||||
{
|
||||
var n = Normalize(StripZucht(rawName));
|
||||
if (n.Length == 0) return null;
|
||||
if (createdAnimalByName.TryGetValue(n, out var fromLoadable) && persisted.Contains(fromLoadable))
|
||||
return fromLoadable;
|
||||
if (allDbNormToGid.TryGetValue(n, out var fromDb) && persisted.Contains(fromDb))
|
||||
return fromDb;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var el in existingWithNullParent)
|
||||
{
|
||||
if (!sourceByName.TryGetValue(el.Name, out var sl)) continue;
|
||||
var newF = el.FatherId == null ? ResolveParentForBackfill(sl.SireName) : null;
|
||||
var newM = el.MotherId == null ? ResolveParentForBackfill(sl.DamName) : null;
|
||||
if (newF is null && newM is null) continue;
|
||||
parentFksBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Litters.FirstOrDefaultAsync(l => l.Id == el.Id);
|
||||
if (row is not null)
|
||||
{
|
||||
if (newF is not null) row.FatherId = newF;
|
||||
if (newM is not null) row.MotherId = newM;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||
if (parentLinksAdded > 0)
|
||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
@@ -411,7 +468,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
return new ImportReport(
|
||||
Executed: execute,
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
|
||||
@@ -49,7 +49,7 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
||||
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
||||
|
||||
@@ -179,10 +179,11 @@ describe('Farbschlag catalog', () => {
|
||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||
})
|
||||
|
||||
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
|
||||
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
||||
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
||||
expect(CATALOG_SIZE).toBe(61)
|
||||
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
|
||||
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
|
||||
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
|
||||
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
|
||||
expect(CATALOG_SIZE).toBe(66)
|
||||
})
|
||||
|
||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
@@ -414,8 +415,12 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
})
|
||||
|
||||
it('A- + cchm/ch -> CP-<base colour>', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||
})
|
||||
|
||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
@@ -471,10 +476,50 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
|
||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
|
||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
|
||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||
|
||||
it('all new -Hell het entries exist in catalog', () => {
|
||||
for (const n of [
|
||||
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
|
||||
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
|
||||
]) {
|
||||
expect(has(n)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||
})
|
||||
|
||||
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
|
||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
|
||||
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||||
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,27 +107,29 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
|
||||
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
||||
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
||||
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
||||
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
||||
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
||||
// in her live data (god: extract animals.json) so the re-import name-matches and
|
||||
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
||||
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
|
||||
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
|
||||
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
|
||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
|
||||
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
|
||||
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
|
||||
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
|
||||
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
|
||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
]
|
||||
|
||||
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||
@@ -207,12 +209,14 @@ function baseColourFor(g: Genotype): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
||||
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
||||
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
||||
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
||||
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
|
||||
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
|
||||
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
|
||||
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
|
||||
*/
|
||||
function colourpointName(g: Genotype): string | null {
|
||||
const c = resolvedPair(g, 'C')
|
||||
@@ -227,9 +231,9 @@ function colourpointName(g: Genotype): string | null {
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// A- colourpoint -> CP-<base colour>, base as if C were full.
|
||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
return base ? `CP-${base}` : null
|
||||
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
||||
}
|
||||
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
|
||||
@@ -340,42 +340,67 @@
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"sortOrder": 55,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"sortOrder": 57,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"sortOrder": 59,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 57
|
||||
"sortOrder": 61
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 59
|
||||
"sortOrder": 63
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 60
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
}
|
||||
]
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
{
|
||||
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
||||
"dob": "16.01.2023",
|
||||
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name kept in the merged record's v.d. spelling: extract.py decision matching uses norm_name (no v.d.<->von den fold) — workaround until the canon_pair matching fix lands.",
|
||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
}
|
||||
|
||||
@@ -158,6 +158,10 @@ def parse_detail(text):
|
||||
tail = text[dob.end():]
|
||||
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
|
||||
tail = tail.lstrip(" ,").strip()
|
||||
# FIX-4 (Skarlett): strip trailing "/ +YEAR" death-year artifacts leaked from compact
|
||||
# chart cells (e.g. "… rere / +2018"). The DEATH regex still captures the year from
|
||||
# the full cell text, so it appears as a death-date conflict — not a genotype conflict.
|
||||
tail = re.sub(r"\s*/\s*\+\d{4}\s*$", "", tail).strip()
|
||||
if gt.looks_like_genotype(tail):
|
||||
geno = tail
|
||||
return (dob.group(1) if dob else "",
|
||||
@@ -518,10 +522,11 @@ def _alleles_compatible(a, b):
|
||||
if a == b:
|
||||
return True
|
||||
if a == "?" or b == "?":
|
||||
return False # unknown vs filled = contradiction (D- vs DD)
|
||||
return True # specific-wins: unknown allele is compatible with any
|
||||
# specified value (C- vs CC -> CC; G- vs Gg -> Gg)
|
||||
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
|
||||
if ba != bb:
|
||||
return False # different base allele = real value diff (E vs e)
|
||||
return False # different base allele = real value diff (E vs e, D vs d)
|
||||
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
|
||||
|
||||
|
||||
@@ -622,9 +627,12 @@ def dedup(animals):
|
||||
if a.get("deaf") is not None:
|
||||
deaf_seen.add(a["deaf"])
|
||||
tags_set.update(a.get("tags", []))
|
||||
# pick the richest genotype (most mapped loci, then longest raw)
|
||||
# pick the richest genotype: most mapped loci, then fewest unknowns ('?' alleles = specific
|
||||
# wins, FIX-2), then longest raw string as final tiebreaker.
|
||||
def _specificity(gd):
|
||||
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
|
||||
best = max((a["genotype"] for a in grp),
|
||||
key=lambda gd: (len(gd["mapped8locus"]), len(gd["rawGenotype"])))
|
||||
key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
|
||||
out = {
|
||||
"id": slug(base["name"], base["dob"]),
|
||||
"name": base["name"],
|
||||
@@ -887,21 +895,30 @@ def apply_dob_remaps(raw_animals, path):
|
||||
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
|
||||
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
|
||||
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
|
||||
norm_name(name)+norm_dob(dob). Tolerates a missing/garbled file. Returns the remap count.
|
||||
canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
|
||||
Tolerates a missing/garbled file. Returns the remap count.
|
||||
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
|
||||
remaps = {}
|
||||
remaps_full = {} # (nameCanon, zuchtCanon, dob) -> correctDob — decision carries Zucht
|
||||
remaps_name = {} # (nameCanon, dob) -> correctDob — no Zucht in decision
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for r in (json.load(fh).get("resolutions") or []):
|
||||
if r.get("correctDob"):
|
||||
remaps[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r["correctDob"]
|
||||
nc, zc = canon_pair(r.get("name", ""))
|
||||
dob = norm_dob(r.get("dob", ""))
|
||||
if zc:
|
||||
remaps_full[(nc, zc, dob)] = r["correctDob"]
|
||||
else:
|
||||
remaps_name[(nc, dob)] = r["correctDob"]
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
if not remaps:
|
||||
if not remaps_full and not remaps_name:
|
||||
return 0
|
||||
n = 0
|
||||
for a in raw_animals:
|
||||
new = remaps.get((norm_name(a.get("name", "")), norm_dob(a.get("dob", ""))))
|
||||
nc, zc = canon_pair(a.get("name", ""))
|
||||
dob = norm_dob(a.get("dob", ""))
|
||||
new = remaps_full.get((nc, zc, dob)) or remaps_name.get((nc, dob))
|
||||
if new and a.get("dob") != new:
|
||||
a["dob"] = new
|
||||
n += 1
|
||||
@@ -911,23 +928,36 @@ def apply_dob_remaps(raw_animals, path):
|
||||
def apply_conflict_decisions(merged, conflicts, path):
|
||||
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
|
||||
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
|
||||
farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A
|
||||
matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype`
|
||||
(breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates
|
||||
a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||
decisions = {}
|
||||
farbschlag?, source}]}. Match = canon_pair(name)+(dob):
|
||||
- When the decision name CARRIES a Zucht (zuchtCanon != ''), match on the FULL
|
||||
(nameCanon, zuchtCanon, dob) triple — preserves the C3 rule that same name+DOB but
|
||||
different Zucht = different animal.
|
||||
- When the decision has NO Zucht, fall back to (nameCanon, dob) name-only match.
|
||||
Both spellings v.d. / von den fold to the same canon. A matching animal: clear its conflict,
|
||||
mark resolvedByDecision; an explicit `genotype` (breeder notation) is parsed and becomes
|
||||
authoritative, `farbschlag` overrides too. Tolerates a missing/empty/garbled file.
|
||||
Returns the number of conflicts resolved. (god/HUMANQUESTION D.)"""
|
||||
decisions_full = {} # (nameCanon, zuchtCanon, dob) -> r — when decision carries a Zucht
|
||||
decisions_name = {} # (nameCanon, dob) -> r — fallback, decision has no Zucht
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for r in (json.load(fh).get("resolutions") or []):
|
||||
decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r
|
||||
nc, zc = canon_pair(r.get("name", ""))
|
||||
dob = norm_dob(r.get("dob", ""))
|
||||
if zc:
|
||||
decisions_full[(nc, zc, dob)] = r
|
||||
else:
|
||||
decisions_name[(nc, dob)] = r
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
if not decisions:
|
||||
if not decisions_full and not decisions_name:
|
||||
return 0
|
||||
|
||||
resolved = 0
|
||||
for a in merged:
|
||||
d = decisions.get((norm_name(a["name"]), norm_dob(a["dob"])))
|
||||
nc, zc = canon_pair(a["name"])
|
||||
dob = norm_dob(a["dob"])
|
||||
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
|
||||
if not d:
|
||||
continue
|
||||
a["resolvedByDecision"] = True
|
||||
|
||||
@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
||||
- Nach Zusammenführung (eindeutige Tiere): **621**
|
||||
- davon mit Geburtsdatum: 326
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||
- Konflikte zur Klärung: **8**
|
||||
- Konflikte zur Klärung: **5**
|
||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
||||
- Fotos zugeordnet: **137**
|
||||
- Würfe aus der Wurfchronik: **752**
|
||||
@@ -25,12 +25,9 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
||||
|
||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||
|---|---|---|---|---|---|
|
||||
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
||||
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
||||
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
|
||||
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
||||
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||
| Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
||||
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
||||
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
||||
|
||||
@@ -129,7 +126,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
||||
|
||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||
|---|---|---|
|
||||
| `/+` | 7 | ? |
|
||||
| `/+` | 6 | ? |
|
||||
| `-g` | 2 | ? |
|
||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||
| `Cc[]` | 1 | ? |
|
||||
|
||||
@@ -102,6 +102,63 @@ check("apply_conflict_decisions returns resolved count", n == 2)
|
||||
check("missing decisions file tolerated (returns 0)",
|
||||
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
||||
|
||||
# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
|
||||
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
|
||||
_json.dump({"resolutions": [
|
||||
{"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den'
|
||||
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||
merged_vd = [
|
||||
{"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.'
|
||||
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_vd = [{"id": "vw"}]
|
||||
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
|
||||
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
|
||||
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
|
||||
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
|
||||
_json.dump({"resolutions": [
|
||||
{"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision
|
||||
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||
merged_vd2 = [
|
||||
{"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den'
|
||||
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_vd2 = [{"id": "vw2"}]
|
||||
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
|
||||
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
|
||||
try: os.remove(dec_vd)
|
||||
except OSError: pass
|
||||
|
||||
# FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation)
|
||||
dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json")
|
||||
_json.dump({"resolutions": [
|
||||
# Decision only for Luna from ZdkC, NOT Luna from Black Forest
|
||||
{"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
||||
"decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"},
|
||||
]}, open(dec_c3, "w", encoding="utf-8"))
|
||||
merged_c3 = [
|
||||
{"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
||||
"conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
||||
{"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020",
|
||||
"conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}]
|
||||
n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3)
|
||||
check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1)
|
||||
check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False)
|
||||
check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True)
|
||||
check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf")
|
||||
try: os.remove(dec_c3)
|
||||
except OSError: pass
|
||||
|
||||
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
|
||||
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
|
||||
_json.dump({"resolutions": [
|
||||
@@ -128,23 +185,95 @@ except OSError: pass
|
||||
try: os.remove(dec_path)
|
||||
except OSError: pass
|
||||
|
||||
# --- "presence wins" conflict rule (Julian) ---
|
||||
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are.
|
||||
# --- "presence wins" + "specific wins" conflict rules (Julian) ---
|
||||
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing FILLED values are.
|
||||
# FIX-2 (specific-wins): unknown allele '?' vs any specified value is also NOT a conflict —
|
||||
# the specific value wins (C- vs CC -> CC; G- vs Gg -> Gg; P? vs PP -> PP).
|
||||
check("spsp present vs locus absent -> no conflict",
|
||||
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
|
||||
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
|
||||
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
|
||||
check("DD vs D- (unknown vs filled) -> conflict",
|
||||
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
||||
# FIX-2: '?' vs specified = specific wins (was: contradiction)
|
||||
check("FIX-2: DD vs D- (specific wins: DD wins) -> NOT conflict",
|
||||
not e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}]))
|
||||
check("FIX-2: C- vs Cc[h] (specific wins: c^h wins) -> NOT conflict",
|
||||
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
||||
check("FIX-2: C- vs CC (specific wins: CC) -> NOT conflict",
|
||||
not e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "C"]}]))
|
||||
check("FIX-2: G- vs Gg (specific wins) -> NOT conflict",
|
||||
not e._genotype_conflict([{"G": ["G", "?"]}, {"G": ["G", "g"]}]))
|
||||
check("FIX-2: PP vs P? (specific wins: PP) -> NOT conflict",
|
||||
not e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "?"]}]))
|
||||
# Genuine value contradictions (both alleles specified but different) still quarantine
|
||||
check("Ee vs ee (different base allele) -> conflict",
|
||||
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
|
||||
check("C- vs Cc[h] -> conflict",
|
||||
e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}]))
|
||||
check("c[h] vs c[chm] (different modifiers) -> conflict",
|
||||
check("DD vs Dd (both specified, D vs d) -> conflict",
|
||||
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
|
||||
check("PP vs Pp (both specified) -> conflict",
|
||||
e._genotype_conflict([{"P": ["P", "P"]}, {"P": ["P", "p"]}]))
|
||||
check("c[h] vs c[chm] (different modifiers, both specified) -> conflict",
|
||||
not e._alleles_compatible("c^h", "c^chm"))
|
||||
check("identical genotypes -> no conflict",
|
||||
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}]))
|
||||
|
||||
# FIX-2 MERGE: specific allele must survive the merge regardless of which variant comes first.
|
||||
# dedup() picks the most specific genotype (fewest '?' alleles); C- vs CC -> CC must win.
|
||||
def _minimal_animal(name, dob, mapped):
|
||||
"""Build a minimal raw animal dict suitable for dedup()."""
|
||||
from genotype import parse as gparse
|
||||
raw = " ".join(f"{l}{''.join(a)}" for l, pa in mapped.items() for a in [pa])
|
||||
return {
|
||||
"name": name, "dob": dob, "death": "", "gender": None,
|
||||
"farbschlag": "", "breeder": "", "zucht": "", "parentRefs": [],
|
||||
"photos": [], "sourceFiles": ["test.xlsx"], "tags": [],
|
||||
"deaf": None, "conflict": False,
|
||||
"genotype": {"mapped8locus": mapped, "rawGenotype": raw, "unmappedTokens": []},
|
||||
"_gen": 0, "_col": 5, "_row": 10, "_file": "test.xlsx",
|
||||
"_zucht": "",
|
||||
}
|
||||
|
||||
# Order A: C- first, CC second
|
||||
animals_merge_a = [
|
||||
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "?"]}), # C-
|
||||
_minimal_animal("TestTier", "01.01.2020", {"C": ["C", "C"]}), # CC
|
||||
]
|
||||
merged_ma, _, _, _ = e.dedup(animals_merge_a)
|
||||
check("FIX-2 merge A (C- first): result has CC not C-",
|
||||
merged_ma[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
|
||||
|
||||
# Order B: CC first, C- second (must give same result)
|
||||
animals_merge_b = [
|
||||
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "C"]}), # CC
|
||||
_minimal_animal("TestTier2", "02.02.2020", {"C": ["C", "?"]}), # C-
|
||||
]
|
||||
merged_mb, _, _, _ = e.dedup(animals_merge_b)
|
||||
check("FIX-2 merge B (CC first): result has CC not C-",
|
||||
merged_mb[0]["genotype"]["mapped8locus"].get("C") == ["C", "C"])
|
||||
|
||||
# G- vs Gg: Gg must win
|
||||
animals_merge_g = [
|
||||
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "?"]}), # G-
|
||||
_minimal_animal("TestGGerbil", "03.03.2020", {"G": ["G", "g"]}), # Gg
|
||||
]
|
||||
merged_mg, _, _, _ = e.dedup(animals_merge_g)
|
||||
check("FIX-2 merge G (G- vs Gg): Gg wins",
|
||||
merged_mg[0]["genotype"]["mapped8locus"].get("G") == ["G", "g"])
|
||||
|
||||
# --- FIX-4: Skarlett parse artifact — trailing "/ +YEAR" stripped from geno, death captured ---
|
||||
dob4, death4, geno4 = e.parse_detail("Skarlett,*17.04.2016, aa C- DD ee Gg PP spsp rere / +2018")
|
||||
check("FIX-4: '/ +YEAR' artifact stripped from geno tail",
|
||||
geno4 == "aa C- DD ee Gg PP spsp rere")
|
||||
check("FIX-4: death year still captured from full cell text",
|
||||
death4 == "2018")
|
||||
check("FIX-4: DOB still correct",
|
||||
dob4 == "17.04.2016")
|
||||
# Without artifact — must be unchanged
|
||||
dob5, death5, geno5 = e.parse_detail("*01.01.2020, aa C- DD ee Gg PP spsp rere")
|
||||
check("FIX-4: no artifact -> geno unchanged",
|
||||
geno5 == "aa C- DD ee Gg PP spsp rere")
|
||||
check("FIX-4: no artifact -> no spurious death",
|
||||
death5 == "")
|
||||
|
||||
# --- name-bleed guard (a parent name is not a Farbschlag) ---
|
||||
check("v.d. name rejected", e.looks_like_animal_name("Tennessee von den Kleinen Chaoten"))
|
||||
check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))
|
||||
|
||||
Reference in New Issue
Block a user