Compare commits

..

17 Commits

Author SHA1 Message Date
97acaf6b10 WEB-6a: Oeffentliche Webseite — Gestaltung & Grundseiten
- SiteRenderer: neues warmes, mobile-first CSS (Georgia-Headings, Scrollnav,
  site-footer-nav mit Impressum/Datenschutz-Links)
- AbgabetiereList: Gruppen-Karten statt Einzelkarten (Becken-Gruppierung,
  Geschlecht-Label, Verfuegbar-Badge, Foto-Strip, pro-Tier-Detail-Zeilen
  mit Farbschlag + geboren-am + Persoenlichkeit/CharacterNote)
- SiteSnapshotService: ForSale-Snapshot um gender, dateOfBirth, characterNote
  erweitert
- Migration AddImpressumDatenschutzPages: Impressum + Datenschutz als
  Platzhalter-Seiten (footer-only, kein navOrder-Eintrag)
- SeedCms: Impressum/Datenschutz auch in HasData (SQLite-Tests)
- Tests: CmsTests angepasst (8 Seiten), 6 neue SiteRendererTests
  (Gruppen-Karte, Geschlecht, Badge, DateOfBirth, Solo-Tier,
   Impressum/Datenschutz, Footer-Links)
Gate: dotnet 131/131 + vitest 78/78 + e2e 94/94 + build clean
2026-06-06 15:15:54 +02:00
9ed68ba38a Merge feature/import-polish: decision-matching (name,zucht,dob), specific-wins dedup, parent-FK-backfill (DB-weit via allDbNormToGid), Skarlett-Parse-Fix [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 50s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 15:04:41 +02:00
dfcd296119 IMPORT-POLISH v2: 3 Korrekturen nach god-Review
FIX-1 ZUCHT: apply_conflict_decisions/apply_dob_remaps matchen jetzt
auf das VOLLE canon_pair-Tupel (nameCanon, zuchtCanon, dob) wenn die
Decision eine Zucht traegt; Fallback name-only wenn keine Zucht.
C3-Regel gewahrt: gleicher Name+DOB, andere Zucht -> kein Hit.
Neuer Regression-Test: Luna ZdkC-Decision trifft nur luna-kc,
nicht luna-bf (andere Zucht).

FIX-2 MERGE: dedup() waehlt das spezifischste Genotyp (fewest '?'
alleles) als sekundaeren Tiebreaker nach locus-count. CC schlaegt C-,
Gg schlaegt G- unabhaengig von der Reihenfolge. 3 neue Merge-Tests
(C- first/CC first/G-vsGg).

FIX-3 BACKFILL allDbNormToGid: ResolveParentForBackfill prueft jetzt
BEIDE Quellen: (a) createdAnimalByName (aktiver Lauf) und
(b) allDbNormToGid (alle DB-Tiere). Decktt den kritischen Fall:
Elterntier in fruehrem Lauf geladen, in diesem Lauf absent vom
Extract. Neuer 3-Lauf-SQLite-Test: Lauf 1 null-Vater, Lauf 2 laedt
Vater, Lauf 3 backfillt via allDbNormToGid.

Gate: 125/125 C#-Tests, Python ALL PASS, has-pending-model-changes=No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:03:17 +02:00
db85a6e0dc docs(HUMANQUESTION): C9 beantwortet - 'hell' im Farbschlag-Namen = c[h]-Allel (cchm/ch), ohne = cchm/cchm; CP-Fuchs ist Sammelbegriff (Phaenotyp schneeweiss/schwarzaeugig, Loci unbekannt) -> GEN-3g beauftragt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 50s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 10s
2026-06-06 14:53:39 +02:00
0c94cfcbf1 IMPORT-POLISH: 4 Importer-Fixes nach Re-Import #2
FIX-1 decision-matching: apply_conflict_decisions/apply_dob_remaps
nutzen jetzt canon_pair(name)[0] als Match-Key (Dedup-Identitaet:
call-name ohne Zucht, v.d.<->von den gefaltet). Workaround-Spelling
v.d. in Victoria Welbys Decision bleibt erhalten; beide Formen
matchen jetzt. Kommentar im decision-Eintrag aktualisiert.

FIX-2 specific-wins: _alleles_compatible aendert '? vs x = False'
-> '? vs x = True' (spezifischer Wert gewinnt). C- vs CC, G- vs Gg,
P? vs PP sind kein Konflikt mehr. Echte Wert-Widersprueche (DD vs Dd,
Ee vs ee, PP vs Pp) bleiben Konflikte. Loest Enya, Ella, Zac
automatisch (Konflikte 8->5 erwartet). 2 bestehende Tests angepasst,
7 neue Tests.

FIX-3 parent-FK backfill: nach dem Wurfchronik-Rueckverknuepfungs-
Block iteriert ImportService.RunAsync ueber bereits importierte
Wuerfe mit null Father/MotherId und setzt fehlende FKs wenn das
Elterntier jetzt ladbar ist. Trockenlauf zaehlt, Execute schreibt.
LitterSummary.ParentFksBackfilled + 2 neue C#-Tests (SQLite).

FIX-4 Skarlett-Artefakt: parse_detail() strippt trailing / +YEAR
aus dem Genotyp-Tail (re.sub). Sterbejahr bleibt als death-Date
erhalten -> Skarlett erscheint als reiner Sterbedatum-Konflikt.
2 neue Python-Tests.

Gate: 124/124 C#-Tests, Python test_extract/test_genotype ALL PASS,
has-pending-model-changes = No.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:27 +02:00
5eadd89bb6 REIMPORT-2: Victoria-Welby-Entscheidung greift (v.d.-Schreibweise als Workaround fuer norm_name-Matching), Re-Import #2 ausgefuehrt (+13 Tiere/+31 Wuerfe/+9 Fotos, C hat beide Eltern); HUMANQUESTION D6 = letzte 5 offene Konflikte
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 53s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 2m27s
2026-06-06 14:31:14 +02:00
f9a68deb7a Merge feature/web-3: lokale Webseiten-Vorschau (GET /api/preview/{**path} + /webseite/vorschau iframe-Vorschau mit Geraete-Umschalter) [god-QA validated]
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m51s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-06 14:22:31 +02:00
a693095886 ... 2026-06-06 13:38:03 +02:00
d93e8d1586 Merge feature/gerbil-residency-toggle: residency checkbox on Gerbil create/edit form [god-QA pending] 2026-06-06 12:15:20 +02:00
114bbd92c8 Merge feature/extract-band-aware (presence-wins dedup + correctDob): conflicts 19→15 [god-QA pending] 2026-06-06 12:15:01 +02:00
a8d8ae0dfc conflict-decisions: correctDob remaps a wrong-birthdate duplicate before dedup
god added a `correctDob` (DD.MM.YYYY) decisions field: the matched (name+dob)
record is a DUPLICATE with a wrong birthdate → remap its DOB to correctDob so
dedup MERGES it into the canonical same-named animal. apply_dob_remaps runs
BEFORE dedup (it changes the dedup identity); tolerates a missing file; logged
as "DOB-Remaps: N". First use: Chelsea *15.10.2021 → *02.04.2021 (merges into
the canonical record). test_extract covers the remap + that both records then
share one name+dob identity.

Extractor-only. python + dotnet 121/121 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:14:23 +02:00
522f2eec51 docs(HUMANQUESTION): C8 answered — Himalaya exists (A- c[h]c[h]); Hermelin = aa c[h]c[h]; both kept 2026-06-06 12:13:20 +02:00
3f71d8e28e dedup: 'presence wins' — present-vs-absent token is not a conflict (Julian)
Breeder merge rule: when two source variants of the SAME animal differ ONLY
by a token PRESENT in one and ABSENT in the other — a whole locus (e.g. spsp
charted in one source, omitted in another) or a modifier on the same base
allele (e^f vs e, the [f] marker) — keep the present token; that is NOT a
conflict. Genuine VALUE contradictions still quarantine: different base
alleles (Ee↔ee), unknown-vs-filled (D-↔DD), different modifiers (c[h]↔c[chm]),
C-↔Cc[h], P-↔Pp.

Replaces the old `len(distinct normalized geno keys) > 1` test with
_genotype_conflict() (per-locus, per-allele compatibility; '?'-vs-filled is a
contradiction, modifier-present-vs-absent and whole-locus-absence are not).
Markers/flags (WP/DP/WFNZ/hörend) are already tags/flags, never genotype, so
they never reach conflict detection; empty Farbschlag/death already don't
conflict (only non-empty values are compared).

Clears Daja (keep spsp), Ichika (keep ee[f]) and the D4 marker cases:
Konflikte 19 -> 15. test_extract covers spsp/[f] present-vs-absent =
no conflict and the four genuine-contradiction shapes. python + dotnet
121/121 green; extractor-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:45 +02:00
0e7ec5ab61 BESTAND-FILTER follow-up: residency toggle on the Gerbil create/edit form
Completes residency end-to-end: a 'Gehört zum eigenen Bestand' checkbox on the
Rennmaus-Formular (default on for new animals) lets the user mark a tier as an
external ancestor by hand — previously isResident was only set at import. Wired
through FormState + formFromGerbil (defaults true) + the CreateGerbil payload.

German via de.ts (LF). e2e: edit Krümel, untick Bestand, save -> detail shows
the 'Extern' badge (proves form field -> payload -> persistence). Gate green:
build, eslint, vitest 78, e2e 86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:12:32 +02:00
12604fba7c data(import): D4 Victoria Welby resolved (ee[f]) — C's mother → C gets both parents — Julian 2026-06-06 12:12:23 +02:00
311c5461fd data(import): Chelsea (merge to *02.04.2021, correctDob) + Vestra (DD) resolved — Julian 2026-06-06 12:11:41 +02:00
3634d6ef9a data(import): D3 Silvain resolved (ee, Pp) — Julian 2026-06-06 12:09:41 +02:00
21 changed files with 2471 additions and 214 deletions

View File

@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
public CmsTests(ApiFactory factory) => _client = factory.CreateClient(); public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
[Fact] [Fact]
public async Task Snapshot_has_site_nav_and_six_seeded_pages() public async Task Snapshot_has_site_nav_and_seeded_pages()
{ {
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot")); var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var root = doc.RootElement; var root = doc.RootElement;
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString()); Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList(); var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav); Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
var pages = root.GetProperty("pages").EnumerateArray().ToList(); var pages = root.GetProperty("pages").EnumerateArray().ToList();
Assert.Equal(6, pages.Count); // 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
Assert.Equal(8, pages.Count);
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array // abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere"); var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
var listBlock = abg.GetProperty("blocks").EnumerateArray() var listBlock = abg.GetProperty("blocks").EnumerateArray()

View File

@@ -321,6 +321,192 @@ namespace GerbilManager.Tests
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g)); 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] [Theory]
[InlineData("01.02.2020", 2020, 2, 1)] [InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)] [InlineData("5.3.21", 2021, 3, 5)]

View File

@@ -69,10 +69,11 @@ public class SiteRendererTests
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone }, ["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
}; };
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals) private static JsonObject AbgabetiereBlock(string intro,
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
{ {
var animalArr = new JsonArray(); var animalArr = new JsonArray();
foreach (var (name, farbe, group, photo) in animals) foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
{ {
var photos = new JsonArray(); var photos = new JsonArray();
if (photo is not null) photos.Add(photo); if (photo is not null) photos.Add(photo);
@@ -81,6 +82,9 @@ public class SiteRendererTests
["name"] = name, ["name"] = name,
["farbschlag"] = farbe, ["farbschlag"] = farbe,
["group"] = group, ["group"] = group,
["gender"] = gender,
["dateOfBirth"] = dob,
["characterNote"] = note,
["photos"] = photos, ["photos"] = photos,
["aiSaleText"] = (JsonNode?)null, ["aiSaleText"] = (JsonNode?)null,
}); });
@@ -92,6 +96,7 @@ public class SiteRendererTests
}; };
} }
// ── Tests ──────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────
[Fact] [Fact]
@@ -211,16 +216,52 @@ public class SiteRendererTests
} }
[Fact] [Fact]
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag() public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
{ {
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere", var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg"))))); Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"]; var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Krümel", html); Assert.Contains("Krümel", html);
Assert.Contains("CP-Agouti", html); Assert.Contains("CP-Agouti", html);
Assert.Contains("Großbecken", html); Assert.Contains("Großbecken", html);
Assert.Contains("/photos/files/abc.jpg", html); Assert.Contains("/photos/files/abc.jpg", html);
Assert.Contains("cms-animal-card", html); Assert.Contains("cms-group-card", html);
}
[Fact]
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Weibchen", html);
Assert.Contains("Verfügbar", html);
Assert.Contains("cms-badge--free", html);
}
[Fact]
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("März 2024", html);
Assert.Contains("Sehr neugierig und verspielt.", html);
}
[Fact]
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
{
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
Blocks(AbgabetiereBlock("",
("Einzelkind", "Zobel", null, null, "male", null, null)))));
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
Assert.Contains("Einzelkind", html);
Assert.Contains("Männchen", html);
Assert.Contains("cms-group-card", html);
} }
[Fact] [Fact]
@@ -283,6 +324,44 @@ public class SiteRendererTests
Assert.Equal(7, files.Count); Assert.Equal(7, files.Count);
} }
[Fact]
public void Render_impressum_and_datenschutz_pages_are_rendered()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
var files = SiteRenderer.Render(snap);
Assert.True(files.ContainsKey("impressum/index.html"));
Assert.True(files.ContainsKey("datenschutz/index.html"));
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
}
[Fact]
public void Footer_contains_impressum_and_datenschutz_links()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var startHtml = SiteRenderer.Render(snap)["index.html"];
Assert.Contains("impressum/index.html", startHtml);
Assert.Contains("datenschutz/index.html", startHtml);
// links in footer, not nav
Assert.Contains("site-footer-link", startHtml);
}
[Fact]
public void Footer_links_use_correct_relative_path_from_subpage()
{
var snap = MakeSnapshot(
("start", "Startseite", Blocks()),
("kontakt", "Kontakt", Blocks()));
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
// subpage needs ../ prefix for footer links
Assert.Contains("../impressum/index.html", kontaktHtml);
Assert.Contains("../datenschutz/index.html", kontaktHtml);
}
// ── Markdown tests ─────────────────────────────────────────────────────── // ── Markdown tests ───────────────────────────────────────────────────────
[Theory] [Theory]

View File

@@ -193,6 +193,15 @@ public class ApplicationContext : DbContext
(6, "kontakt", "Kontakt"), (6, "kontakt", "Kontakt"),
}; };
// WEB-6a: footer-only legal pages (not in main nav)
(int n, string Slug, string Title, string BodyMd)[] legalPages =
{
(7, "impressum", "Impressum",
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
(8, "datenschutz", "Datenschutz",
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
};
var pageRows = new List<Page>(); var pageRows = new List<Page>();
var blockRows = new List<Block>(); var blockRows = new List<Block>();
foreach (var p in pages) foreach (var p in pages)
@@ -211,6 +220,12 @@ public class ApplicationContext : DbContext
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList, Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
Data = "{\"mode\":\"auto\",\"intro\":\"\"}", Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
}); });
foreach (var lp in legalPages)
{
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
}
modelBuilder.Entity<Page>().HasData(pageRows); modelBuilder.Entity<Page>().HasData(pageRows);
modelBuilder.Entity<Block>().HasData(blockRows); modelBuilder.Entity<Block>().HasData(blockRows);

View File

@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
private static string RenderAbgabetiereList(JsonObject d) private static string RenderAbgabetiereList(JsonObject d)
{ {
var intro = Str(d["intro"]); var intro = Str(d["intro"]);
var animals = d["animals"] as JsonArray ?? []; var animals = (d["animals"] as JsonArray ?? [])
.OfType<JsonObject>().ToList();
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n"); var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
if (!string.IsNullOrWhiteSpace(intro)) if (!string.IsNullOrWhiteSpace(intro))
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
if (animals.Count == 0) if (animals.Count == 0)
{ {
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n"); sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
sb.Append("</section>\n");
return sb.ToString();
} }
else
// Group by Becken name; null group → solo card keyed by animal name
var groups = animals
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
.ToList();
sb.Append(" <div class=\"cms-group-list\">\n");
foreach (var grp in groups)
{ {
sb.Append(" <div class=\"cms-animal-grid\">\n"); var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
foreach (var node in animals) var groupName = isSolo ? null : grp.Key;
var memberList = grp.ToList();
// Determine group gender label
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
string genderLabel = genders.Count == 1
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
sb.Append(" <article class=\"cms-group-card\">\n");
sb.Append(" <div class=\"cms-group-header\">\n");
if (!string.IsNullOrEmpty(groupName))
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
var metaParts = new List<string>();
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
sb.Append(" </div>\n");
// Gallery: first photo of each animal in the group
var gallery = memberList
.Select(a => (
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
name: Str(a["name"]) ?? ""))
.Where(x => !string.IsNullOrEmpty(x.url))
.ToList();
if (gallery.Count > 0)
{ {
if (node is not JsonObject a) continue; sb.Append(" <div class=\"cms-group-gallery\">\n");
var name = H(Str(a["name"]) ?? ""); foreach (var (url, name) in gallery)
var farbe = H(Str(a["farbschlag"]) ?? ""); sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
var group = Str(a["group"]);
var saleText = Str(a["aiSaleText"]);
var photos = a["photos"] as JsonArray ?? [];
sb.Append(" <article class=\"cms-animal-card\">\n");
// Profile photo (first photo)
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
if (!string.IsNullOrEmpty(firstPhoto))
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
sb.Append(" <div class=\"cms-animal-info\">\n");
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
if (!string.IsNullOrEmpty(farbe))
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
if (!string.IsNullOrWhiteSpace(group))
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
if (!string.IsNullOrWhiteSpace(saleText))
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
sb.Append(" </div>\n"); sb.Append(" </div>\n");
sb.Append(" </article>\n");
} }
sb.Append(" </div>\n");
// Per-animal detail rows
sb.Append(" <ul class=\"cms-animal-list\">\n");
foreach (var a in memberList)
{
var name = H(Str(a["name"]) ?? "");
var farbe = Str(a["farbschlag"]);
var dob = Str(a["dateOfBirth"]);
var note = Str(a["characterNote"]);
var saleText = Str(a["aiSaleText"]);
sb.Append(" <li class=\"cms-animal-row\">\n");
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
var details = new List<string>();
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
if (details.Count > 0)
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
var personalityText = note ?? saleText;
if (!string.IsNullOrWhiteSpace(personalityText))
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
sb.Append(" </li>\n");
}
sb.Append(" </ul>\n");
sb.Append(" </article>\n");
} }
sb.Append(" </div>\n");
sb.Append("</section>\n"); sb.Append("</section>\n");
return sb.ToString(); return sb.ToString();
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<p>&copy; {H(SiteName)} Mongolische Rennmäuse</p> <p>&copy; {H(SiteName)} Mongolische Rennmäuse</p>
<nav class="site-footer-nav" aria-label="Rechtliches">
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
</nav>
</footer> </footer>
</body> </body>
</html> </html>
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
["abgabebedingungen"] = "Abgabebedingungen", ["abgabebedingungen"] = "Abgabebedingungen",
["farben-genetik"] = "Farben & Genetik", ["farben-genetik"] = "Farben & Genetik",
["kontakt"] = "Kontakt", ["kontakt"] = "Kontakt",
["impressum"] = "Impressum",
["datenschutz"] = "Datenschutz",
}; };
var sb = new StringBuilder(); var sb = new StringBuilder();
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
internal static string SiteCss() => """ internal static string SiteCss() => """
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { :root {
--color-bg: #fdf8f3; --color-bg: #fdf7f0;
--color-surface: #fff; --color-surface: #fff;
--color-text: #2c2c2c; --color-surface-warm: #fef9f4;
--color-muted: #6b6b6b; --color-text: #2a2118;
--color-accent: #c0392b; --color-muted: #7a6d62;
--color-border: #e0d8d0; --color-accent: #b5331a;
--font-body: system-ui, sans-serif; --color-accent-hover: #8f2815;
--max-w: 860px; --color-accent-soft: #fdf0ed;
--color-border: #e8ddd4;
--color-badge-free: #2d7a4a;
--color-badge-free-bg: #e6f4ec;
--font-body: Georgia, "Times New Roman", serif;
--font-ui: system-ui, -apple-system, sans-serif;
--max-w: 900px;
--radius: 10px;
} }
body { body {
font-family: var(--font-body); font-family: var(--font-body);
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
line-height: 1.65; line-height: 1.7;
font-size: 1rem;
} }
a { color: var(--color-accent); text-decoration: none; } a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; color: var(--color-accent-hover); }
img { max-width: 100%; height: auto; display: block; } img { max-width: 100%; height: auto; display: block; }
p { margin-bottom: .75rem; }
/* ── Header ── */ /* ── Header ── */
.site-header { .site-header {
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-bottom: 2px solid var(--color-border);
padding: .75rem 1rem; padding: 0 1rem;
display: flex; display: flex;
flex-wrap: wrap; align-items: stretch;
align-items: center; gap: 0;
gap: .5rem 1.5rem; min-height: 56px;
} }
.site-logo { .site-logo {
font-family: var(--font-body);
font-weight: 700; font-weight: 700;
font-size: 1.1rem; font-size: 1.15rem;
color: var(--color-text); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
} display: flex;
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; } align-items: center;
.site-nav-link { padding-right: 1.5rem;
font-size: .9rem; border-right: 1px solid var(--color-border);
color: var(--color-muted);
padding: .2rem .4rem;
border-radius: 4px;
}
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: #fef0ee;
text-decoration: none; text-decoration: none;
flex-shrink: 0;
}
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
.site-nav {
display: flex;
align-items: center;
gap: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-left: .5rem;
}
.site-nav::-webkit-scrollbar { display: none; }
.site-nav-link {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
padding: .5rem .65rem;
border-radius: 6px;
white-space: nowrap;
transition: color .15s, background .15s;
}
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
.site-nav-link[aria-current="page"] {
color: var(--color-accent);
background: var(--color-accent-soft);
font-weight: 600;
} }
/* ── Main ── */ /* ── Main ── */
.site-main { .site-main {
max-width: var(--max-w); max-width: var(--max-w);
margin: 2rem auto; margin: 2.5rem auto;
padding: 0 1rem 3rem; padding: 0 1rem 4rem;
} }
/* ── Footer ── */ /* ── Footer ── */
.site-footer { .site-footer {
border-top: 1px solid var(--color-border); background: var(--color-surface);
padding: 1.5rem 1rem; border-top: 2px solid var(--color-border);
padding: 1.75rem 1rem;
text-align: center; text-align: center;
font-family: var(--font-ui);
font-size: .85rem; font-size: .85rem;
color: var(--color-muted); color: var(--color-muted);
} }
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
.site-footer-link:hover { color: var(--color-accent); }
/* ── CMS blocks ── */ /* ── CMS blocks ── */
.cms-heading { margin: 1.5rem 0 .5rem; } .cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
h1.cms-heading { font-size: 1.8rem; } h1.cms-heading { font-size: 2rem; margin-top: 0; }
h2.cms-heading { font-size: 1.4rem; } h2.cms-heading { font-size: 1.5rem; }
h3.cms-heading { font-size: 1.2rem; }
.cms-richtext { margin: 1rem 0; } .cms-richtext { margin: 1rem 0; }
.cms-richtext p { margin-bottom: .75rem; } .cms-richtext p { margin-bottom: .75rem; }
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; } .cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
.cms-richtext li { margin-bottom: .3rem; } .cms-richtext li { margin-bottom: .35rem; }
.cms-image { margin: 1.5rem 0; } .cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
.cms-image img { border-radius: 6px; } .cms-image { margin: 1.75rem 0; }
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.cms-gallery { .cms-gallery {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: .75rem; gap: .75rem;
margin: 1.5rem 0; margin: 1.75rem 0;
} }
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; } .cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
.cms-contact { .cms-contact {
font-style: normal; font-style: normal;
font-family: var(--font-ui);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .4rem; gap: .5rem;
margin: 1rem 0; margin: 1.25rem 0;
background: var(--color-surface-warm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 1.25rem;
} }
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; } .cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
/* ── Abgabetiere ── */ /* ── Abgabetiere — group cards ── */
.cms-abgabe { margin: 1rem 0; } .cms-abgabe { margin: 1.25rem 0; }
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; } .cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; } .cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
.cms-animal-grid {
display: grid; .cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1.25rem; .cms-group-card {
}
.cms-animal-card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius);
overflow: hidden; overflow: hidden;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
}
.cms-group-header {
padding: 1rem 1.25rem .75rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .5rem .75rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-warm);
}
.cms-group-name {
font-family: var(--font-body);
font-size: 1.2rem;
font-weight: 700;
color: var(--color-text);
}
.cms-group-meta {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
display: flex;
align-items: center;
gap: .5rem;
} }
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
.cms-animal-info { padding: .875rem; }
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
@media (max-width: 480px) { /* availability badge */
h1.cms-heading { font-size: 1.4rem; } .cms-badge {
.cms-animal-grid { grid-template-columns: 1fr; } display: inline-block;
font-size: .75rem;
font-weight: 600;
font-family: var(--font-ui);
padding: .2rem .55rem;
border-radius: 20px;
letter-spacing: .02em;
text-transform: uppercase;
}
.cms-badge--free {
background: var(--color-badge-free-bg);
color: var(--color-badge-free);
}
/* group photo strip */
.cms-group-gallery {
display: flex;
gap: .375rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: .75rem 1.25rem;
background: #f5ede4;
}
.cms-group-gallery::-webkit-scrollbar { display: none; }
.cms-group-photo {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
}
/* animal detail list */
.cms-animal-list {
list-style: none;
padding: .75rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: .875rem;
}
.cms-animal-row {
display: flex;
flex-direction: column;
gap: .15rem;
padding-bottom: .875rem;
border-bottom: 1px solid var(--color-border);
}
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
.cms-animal-name {
font-family: var(--font-body);
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.cms-animal-detail {
font-family: var(--font-ui);
font-size: .875rem;
color: var(--color-muted);
}
.cms-animal-note {
font-style: italic;
font-size: .925rem;
color: var(--color-text);
margin-top: .1rem;
}
/* ── Responsive ── */
@media (max-width: 600px) {
.site-header { padding: 0 .75rem; min-height: 50px; }
.site-logo { font-size: 1rem; padding-right: 1rem; }
h1.cms-heading { font-size: 1.5rem; }
.site-main { margin-top: 1.5rem; }
.cms-group-photo { width: 110px; height: 110px; }
}
@media (min-width: 700px) {
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
} }
"""; """;

View File

@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
{ {
g.Id, g.Id,
g.Name, g.Name,
g.Gender,
g.DateOfBirth,
g.CharacterNote,
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null, Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
// group = Becken (enclosure) name — matches how the Abgabe composer // group = Becken (enclosure) name — matches how the Abgabe composer
// groups ForSale animals by enclosure-mates (god ruling). null = single. // groups ForSale animals by enclosure-mates (god ruling). null = single.
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
arr.Add(new JsonObject arr.Add(new JsonObject
{ {
["name"] = a.Name, ["name"] = a.Name,
["gender"] = a.Gender.ToString(),
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
["characterNote"] = a.CharacterNote,
["farbschlag"] = a.Farbschlag, ["farbschlag"] = a.Farbschlag,
["group"] = a.Group, ["group"] = a.Group,
["photos"] = photoArr, ["photos"] = photoArr,

View File

@@ -86,7 +86,8 @@ namespace GerbilManagerWebAPI.Import
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule); public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, 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( public sealed record AnimalSummary(
int InSource, int InSource,

View File

@@ -399,10 +399,67 @@ namespace GerbilManagerWebAPI.Import
await _db.SaveChangesAsync(); 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."); 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) if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); 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."); 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)."); 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); int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
if (conflictsResolvedByDecision > 0) if (conflictsResolvedByDecision > 0)
@@ -411,7 +468,7 @@ namespace GerbilManagerWebAPI.Import
return new ImportReport( return new ImportReport(
Executed: execute, 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: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddImpressumDatenschutzPages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Pages",
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
values: new object[,]
{
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
});
migrationBuilder.InsertData(
table: "Blocks",
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
values: new object[,]
{
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
migrationBuilder.DeleteData(
table: "Blocks",
keyColumn: "Id",
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
migrationBuilder.DeleteData(
table: "Pages",
keyColumn: "Id",
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
}
}
}

View File

@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
Order = 1, Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000003"), PageId = new Guid("51720001-0000-0000-0000-000000000003"),
Type = "AbgabetiereList" Type = "AbgabetiereList"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000007"),
Data = "{\"text\":\"Impressum\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000070"),
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
Type = "RichText"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000008"),
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
Order = 0,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "Heading"
},
new
{
Id = new Guid("51720002-0000-0000-0000-000000000080"),
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
Order = 1,
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
Type = "RichText"
}); });
}); });
@@ -989,6 +1021,20 @@ namespace GerbilManagerWebAPI.Migrations
Slug = "kontakt", Slug = "kontakt",
Status = "Published", Status = "Published",
Title = "Kontakt" Title = "Kontakt"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000007"),
Slug = "impressum",
Status = "Published",
Title = "Impressum"
},
new
{
Id = new Guid("51720001-0000-0000-0000-000000000008"),
Slug = "datenschutz",
Status = "Published",
Title = "Datenschutz"
}); });
}); });

View File

@@ -51,8 +51,8 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
| ~~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 | | ~~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 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen | | C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| C8 | **„Himalaya" vs. „Hermelin":** Du hast gesagt `c[h]c[h]` = **Hermelin**. In unserem Katalog gibt es aktuell ZWEI Farben mit `c[h]c[h]`: **Hermelin** (`aa c[h]c[h]`, nicht-agouti) und **Himalaya** (`A- c[h]c[h]`, agouti). Gibt es bei dir „Himalaya" überhaupt, oder ist **alles** mit `c[h]c[h]` einfach **Hermelin** (dann nehmen wir „Himalaya" raus, wie bei Schwarzschimmel)? | Farbschlag-Katalog (Himalaya behalten oder entfernen) | | ~~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 | *(optional, technisch)* Bei den **CP-Fuchs-Farben**: Wodurch unterscheiden sich genetisch **CP-Fuchs****CP-Blaufuchs****CP-Fuchs-Hell**? (Vermutung: Blaufuchs = `dd`-Verdünnung, „-Hell" = `c[chm]c[h]` statt `c[chm]c[chm]` — stimmt das?) Aktuell rechnet das Programm alle drei als „CP-Fuchs"; mit deiner Regel können wir sie genau unterscheiden. Per Hand auswählbar sind sie schon. | Farb-Engine Feinschliff (niedrige Priorität) | | ~~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 |
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert) ### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)* „Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
@@ -61,6 +61,8 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien) ## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art. Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`. Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
@@ -93,10 +95,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-****PP** | ✅ **PP** — Julian | | WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-****PP** | ✅ **PP** — Julian |
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-****DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian | | Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-****DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-****Cc[h]** · E-Locus: **E-****EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian | | Milka of LennyLengo (*09.12.2018) | C-Locus: **C-****Cc[h]** · E-Locus: **E-****EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | ⏳ offen | | Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee****ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian | | Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee****ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`****ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian | | Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`****ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt**es gibt **zwei** „Chelsea v.d. K.C." mit verschiedenem Geburtsdatum: **\*02.04.2021** und **\*15.10.2021**. Zwei verschiedene Tiere, oder ist ein Datum falsch? | ⏳ offen | | Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt**zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt? ### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung. > ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
@@ -105,8 +107,8 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| Tier | Konkreter Konflikt — was stimmt? | Status | | Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---| |---|---|---|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | ⏳ offen | | Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | **DD** — Julian |
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]** (Fuchs ja/nein; DP gleich in beiden) — **das ist die Mutter von „C"!** Sobald geklärt, bekommt C auch seine Mutter. | ⏳ offen | | Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]** **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr | | Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag | | Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) | | Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
@@ -120,6 +122,17 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)* *(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
| Tier | Konkreter Konflikt — was stimmt? |
|---|---|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP****Pp** |
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp****spsp** (Schecke ja/nein) |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016****2018** |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
--- ---
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte) ## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
@@ -146,7 +159,7 @@ sagen, was ergänzt oder gestrichen werden soll:**
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) | | Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe | | E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** | | NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte/Sonder-Kürzel) | in Quarantäne | **C2C6** (C1 ✅ erledigt → D2-Gruppe + Uw/Marker-Tiere lädt Michael nach) | | Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) | | Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
--- ---

View File

@@ -1,18 +0,0 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
version: '3.4'
services:
gerbilmanagerwebapi:
image: gerbilmanagerwebapi
build:
context: .
dockerfile: GerbilManagerWebAPI/Dockerfile
args:
- configuration=Debug
ports:
- 80:80
environment:
- ASPNETCORE_ENVIRONMENT=Development
volumes:
- ~/.vsdbg:/remote_debugger:rw

View File

@@ -1,57 +0,0 @@
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
version: '3.4'
services:
frontend:
image: gerbilmanagerweb
build:
context: .
dockerfile: gerbil-manager-web/Dockerfile
args:
# Der Browser erreicht die API über den am Host veröffentlichten Port
- VITE_API_BASE_URL=http://localhost:80
ports:
- 3000:3000
networks:
- net2
depends_on:
- backend
backend:
image: gerbilmanagerwebapi
build:
context: .
dockerfile: GerbilManagerWebAPI/Dockerfile
environment:
- ConnectionStrings:sqlConnection=server=database; database=GerbilManager; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True
ports:
- 80:80
networks:
- net1
- net2
depends_on:
- database
database:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- MSSQL_SA_PASSWORD=StrongerThenYYou1
- MSSQL_PID=Evaluation
ports:
- "1433:1433"
volumes:
- db-data:/var/opt/mssql
networks:
- net1
volumes:
db-data:
networks:
net1:
name: network1
net2:
name: network2

View File

@@ -27,3 +27,17 @@ test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Marki
// Der Bestand bleibt weiterhin sichtbar. // Der Bestand bleibt weiterhin sichtbar.
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible() await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
}) })
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
skipUnlessMock()
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
await page.goto('/rennmaeuse/kruemel/bearbeiten')
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
await expect(check).toBeChecked()
await check.uncheck()
await page.getByRole('button', { name: t.form.save }).click()
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
})

View File

@@ -24,6 +24,7 @@ interface FormState {
receiverContactId: string receiverContactId: string
genotype: string genotype: string
notes: string notes: string
isResident: boolean
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
receiverContactId: '', receiverContactId: '',
genotype: '', genotype: '',
notes: '', notes: '',
isResident: true,
} }
function formFromGerbil(g: { function formFromGerbil(g: {
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
receiverContactId: string | null receiverContactId: string | null
genotype: string | null genotype: string | null
notes: string | null notes: string | null
isResident?: boolean | null
}): FormState { }): FormState {
return { return {
name: g.name, name: g.name,
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
receiverContactId: g.receiverContactId ?? '', receiverContactId: g.receiverContactId ?? '',
genotype: g.genotype ?? '', genotype: g.genotype ?? '',
notes: g.notes ?? '', notes: g.notes ?? '',
isResident: g.isResident ?? true,
} }
} }
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
receiverContactId: nn(form.receiverContactId), receiverContactId: nn(form.receiverContactId),
genotype: nn(form.genotype), genotype: nn(form.genotype),
notes: nn(form.notes), notes: nn(form.notes),
isResident: form.isResident,
} }
const result = await mutation.run(body) const result = await mutation.run(body)
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`) if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} /> <textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
</label> </label>
<label className="field field--check" title={t.form.isResidentHint}>
<span>{t.form.isResidentLabel}</span>
<input
type="checkbox"
checked={form.isResident}
onChange={(e) => set('isResident', e.target.checked)}
/>
</label>
{mutation.error && <div className="alert alert--error">{mutation.error}</div>} {mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions"> <div className="form-actions">

View File

@@ -116,6 +116,9 @@ export const de = {
none: '— keine Angabe —', none: '— keine Angabe —',
genotypeHint: genotypeHint:
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.', 'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
isResidentLabel: 'Gehört zum eigenen Bestand',
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
save: 'Speichern', save: 'Speichern',
cancel: 'Abbrechen', cancel: 'Abbrechen',
saving: 'Speichern …', saving: 'Speichern …',

View File

@@ -1,5 +1,5 @@
{ {
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.", "_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
"resolutions": [ "resolutions": [
{ {
"name": "Firefly von den Kleinen Chaoten", "name": "Firefly von den Kleinen Chaoten",
@@ -56,6 +56,34 @@
"decision": "C-locus = Cc[h], E-locus = EE", "decision": "C-locus = Cc[h], E-locus = EE",
"genotype": "aa Cc[h] dd EE Gg P- Spsp", "genotype": "aa Cc[h] dd EE Gg P- Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3" "source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Silvain von den Kleinen Chaoten",
"dob": "27.03.2022",
"decision": "E-locus = ee, P-locus = Pp",
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
},
{
"name": "Chelsea von den Kleinen Chaoten",
"dob": "15.10.2021",
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
"correctDob": "02.04.2021",
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
},
{
"name": "Vestra von den Schlossmäusen",
"dob": "08.02.2019",
"decision": "D-locus = DD",
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
},
{
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
"dob": "16.01.2023",
"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"
} }
] ]
} }

View File

@@ -158,6 +158,10 @@ def parse_detail(text):
tail = text[dob.end():] tail = text[dob.end():]
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
tail = tail.lstrip(" ,").strip() 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): if gt.looks_like_genotype(tail):
geno = tail geno = tail
return (dob.group(1) if dob else "", return (dob.group(1) if dob else "",
@@ -502,6 +506,53 @@ def _geno_key(genodict):
return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m)) return "|".join(f"{locus}:{','.join(sorted(m[locus]))}" for locus in sorted(m))
# --- "presence wins" merge rule (Julian) -------------------------------------
# When two source variants of the SAME animal differ ONLY by a token PRESENT in one and
# ABSENT in the other — a whole locus (e.g. spsp recorded in one chart, omitted in another)
# or a modifier on the same base allele (e^f vs e, i.e. the [f] marker) — keep the present
# token; that is NOT a conflict. A genuine VALUE contradiction (different filled alleles:
# E vs e, D vs d, c^h vs c^chm) OR unknown-vs-filled (D- vs DD, the '?' second allele) STILL
# quarantines for human decision. (Markers/flags WP/DP/WFNZ/hörend are already tags/flags,
# never part of the genotype, so they never reach here.)
def _split_allele(a):
return tuple(a.split("^", 1)) if "^" in a else (a, "")
def _alleles_compatible(a, b):
if a == b:
return True
if a == "?" or b == "?":
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, D vs d)
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
def _pair_compatible(p, q):
if len(p) != 2 or len(q) != 2:
return p == q
return ((_alleles_compatible(p[0], q[0]) and _alleles_compatible(p[1], q[1])) or
(_alleles_compatible(p[0], q[1]) and _alleles_compatible(p[1], q[0])))
def _genotype_conflict(mapped_list):
"""True only if two variants GENUINELY contradict at a shared locus. A locus present in
one variant and absent in another is fine (presence wins); so is a modifier present-vs-
absent on the same base allele. Replaces the old `len(distinct geno keys) > 1` test."""
loci = set()
for m in mapped_list:
loci.update(m.keys())
for locus in loci:
pairs = [m[locus] for m in mapped_list if locus in m]
for i in range(len(pairs)):
for j in range(i + 1, len(pairs)):
if not _pair_compatible(pairs[i], pairs[j]):
return True
return False
def dedup(animals): def dedup(animals):
"""Merge by normalise(call-name)+DOB, with the canonical Zucht as """Merge by normalise(call-name)+DOB, with the canonical Zucht as
DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht = DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht =
@@ -552,6 +603,7 @@ def dedup(animals):
parent_refs = list(base["parentRefs"]) parent_refs = list(base["parentRefs"])
genos = set() genos = set()
geno_keys = set() # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text geno_keys = set() # GEN-3b: conflict on NORMALIZED genotype (Uw==G) not raw text
mapped_variants = [] # mapped8locus per variant — for the 'presence wins' conflict test
farb = set() farb = set()
deaths = set() deaths = set()
deaf_seen = set() deaf_seen = set()
@@ -567,6 +619,7 @@ def dedup(animals):
if a["genotype"]["mapped8locus"]: if a["genotype"]["mapped8locus"]:
genos.add(a["genotype"]["rawGenotype"]) genos.add(a["genotype"]["rawGenotype"])
geno_keys.add(_geno_key(a["genotype"])) geno_keys.add(_geno_key(a["genotype"]))
mapped_variants.append(a["genotype"]["mapped8locus"])
if a["farbschlag"]: if a["farbschlag"]:
farb.add(a["farbschlag"]) farb.add(a["farbschlag"])
if a["death"]: if a["death"]:
@@ -574,9 +627,12 @@ def dedup(animals):
if a.get("deaf") is not None: if a.get("deaf") is not None:
deaf_seen.add(a["deaf"]) deaf_seen.add(a["deaf"])
tags_set.update(a.get("tags", [])) 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), 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 = { out = {
"id": slug(base["name"], base["dob"]), "id": slug(base["name"], base["dob"]),
"name": base["name"], "name": base["name"],
@@ -603,8 +659,9 @@ def dedup(animals):
"conflict": False, "conflict": False,
} }
merged.append(out) merged.append(out)
# conflict: same animal, disagreeing NORMALIZED genotype (Uw==G) or farbschlag or death # conflict: same animal, GENUINELY disagreeing genotype (presence-vs-absence is NOT a
if len(geno_keys) > 1 or len(farb) > 1 or len(deaths) > 1: # conflict — Julian's 'presence wins') or >1 distinct farbschlag or >1 distinct death.
if _genotype_conflict(mapped_variants) or len(farb) > 1 or len(deaths) > 1:
out["conflict"] = True out["conflict"] = True
conflicts.append({ conflicts.append({
"id": out["id"], "name": base["name"], "dob": out["dob"], "id": out["id"], "name": base["name"], "dob": out["dob"],
@@ -834,26 +891,73 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
# ------------------------------------------------------------------------ main # ------------------------------------------------------------------------ main
def apply_conflict_decisions(merged, conflicts, path): def apply_dob_remaps(raw_animals, path):
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's """PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?, wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype` canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
(breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates Tolerates a missing/garbled file. Returns the remap count.
a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)""" Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
decisions = {} remaps_full = {} # (nameCanon, zuchtCanon, dob) -> correctDob — decision carries Zucht
remaps_name = {} # (nameCanon, dob) -> correctDob — no Zucht in decision
try: try:
with open(path, encoding="utf-8") as fh: with open(path, encoding="utf-8") as fh:
for r in (json.load(fh).get("resolutions") or []): for r in (json.load(fh).get("resolutions") or []):
decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r if r.get("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): except (OSError, ValueError):
return 0 return 0
if not decisions: if not remaps_full and not remaps_name:
return 0
n = 0
for a in raw_animals:
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
return n
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 = 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 []):
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_full and not decisions_name:
return 0 return 0
resolved = 0 resolved = 0
for a in merged: 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: if not d:
continue continue
a["resolvedByDecision"] = True a["resolvedByDecision"] = True
@@ -904,8 +1008,9 @@ def main():
litters = extract_wurfchronik(args.wurfchronik) litters = extract_wurfchronik(args.wurfchronik)
print(f"Wurfchronik: {len(litters)} Würfe") print(f"Wurfchronik: {len(litters)} Würfe")
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
decisions_path = os.path.join(HERE, "conflict-decisions.json") decisions_path = os.path.join(HERE, "conflict-decisions.json")
dob_remaps = apply_dob_remaps(raw_animals, decisions_path) # before dedup (changes identity)
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path) resolved_by_decision = apply_conflict_decisions(merged, conflicts, decisions_path)
match_stats = match_litters(merged, litters) match_stats = match_litters(merged, litters)
photo_count = sum(len(a["photos"]) for a in merged) photo_count = sum(len(a["photos"]) for a in merged)
@@ -924,7 +1029,7 @@ def main():
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} " print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} " f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} "
f"| Zucht-Splits: {len(zucht_splits)} " f"| DOB-Remaps: {dob_remaps} | Zucht-Splits: {len(zucht_splits)} "
f"| Orphans: {len(orphans)} | Fotos: {photo_count}") f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), " print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig " f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "

View File

@@ -5,10 +5,10 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick ## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **950** - Rohe Tier-Einträge aus den Stammbäumen: **950**
- Nach Zusammenführung (eindeutige Tiere): **622** - Nach Zusammenführung (eindeutige Tiere): **621**
- davon mit Geburtsdatum: 327 - davon mit Geburtsdatum: 326
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
- Konflikte zur Klärung: **19** - Konflikte zur Klärung: **8**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
- Fotos zugeordnet: **137** - Fotos zugeordnet: **137**
- Würfe aus der Wurfchronik: **752** - Würfe aus der Wurfchronik: **752**
@@ -26,21 +26,10 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien | | 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 | | 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 |
| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| Zuleika von den Kleinen Chaoten | 24.10.2015 | aa c[chm]c[h] D- E G P- spsp // aa c[chm]c[h] D- Ee Gg P- spsp // aa c[chm]c[h] DD Ee Gg P- spsp | — | 24.02.2019 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
| Vestra von den Schlossmäusen | 08.02.2019 | Aa Cc[chm] D- EE GG PP Spsp [WP] // Aa Cc[chm] DD EE GG PP Spsp [WP] | — | 26.05.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
| Flint von den Kleinen Chaoten | 23.12.2017 | aa Cc[chm] D- ee Gg P- spsp | — | 10.05.2021 // 10.05.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| 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 | | 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 |
| Milka of LennyLengo | 09.12.2018 | aa C- dd E- Gg P- Spsp // aa Cc[h] dd EE Gg P- Spsp | — | 22.12.2021 | Stammbaum von Alberto Kids, Stammbaum von Stella Kids |
| Silvain von den Kleinen Chaoten | 27.03.2022 | aa c[chm]c[chm] Dd Ee[-] Gg P- Spsp // aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp | — | 31.12.2024 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| 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 | | 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 Hero of Black Forest | 22.02.2018 | AA CC DD EE GG PP [WFNZ] // AA CC DD EE GG PP spsp [WFNZ] | — | 18.06.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids, Stammbaum von Valentino Firehearts Kids |
| Molly of Black Forest | 13.09.2021 | /+, Aa Cc[chm] D- Ee gg P- spsp // Aa Cc[chm] Dd Ee gg Pp spsp | — | 03.05.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
| 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 | | 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 |
| Daja of Little Rose | 16.05.2021 | aa chmchm D- EE Gg P- // aa chmchm D- EE Gg P- spsp | — | — | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts 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 | | 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 |
| Ichika von den Kleinen Chaoten | 19.04.2020 | aa CC D- ee Gg pp spsp // aa CC D- ee[f] Gg pp spsp | — | 27.11.2023 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Watarus Kids |
| Victoria Welby gen. Welby v.d. Kleinen Chaoten | 16.01.2023 | Aa CC D- Ee[f] Gg pp Spsp [DP] // Aa CC D- ee[f] Gg pp Spsp [DP] | Goldfuchsschimmel Punktschecke DP | 17.02.2026 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus 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 | | 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 | | 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 | | 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 |
@@ -101,7 +90,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019) - „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016) - „Charly of Golden Lights“ → Charly of Golden Lights (*05.04.2016)
- „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016) - „Ziwa of Golden Lights“ → Ziwa of Golden Lights (*29.04.2016)
- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021); Chelsea von den Kleinen Chaoten (*15.10.2021) - „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016) - „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016) - „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018) - „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018)
@@ -143,12 +132,12 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `/+` | 7 | ? | | `/+` | 7 | ? |
| `-g` | 2 | ? | | `-g` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) | | `C(C)` | 2 | Schreibweise (C trägt c) |
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
| `Cc[]` | 1 | ? | | `Cc[]` | 1 | ? |
| `-psp` | 1 | ? | | `-psp` | 1 | ? |
| `G(G)` | 1 | ? | | `G(G)` | 1 | ? |
| `/` | 1 | ? | | `/` | 1 | ? |
| `+2018` | 1 | ? | | `+2018` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? | | `c[chm]chm]` | 1 | ? |
| `Dea/dea]` | 1 | ? | | `Dea/dea]` | 1 | ? |
| `DD-Tumor` | 1 | ? | | `DD-Tumor` | 1 | ? |

View File

@@ -101,9 +101,179 @@ check("decision removes both entries from conflicts list", conflicts == [])
check("apply_conflict_decisions returns resolved count", n == 2) check("apply_conflict_decisions returns resolved count", n == 2)
check("missing decisions file tolerated (returns 0)", check("missing decisions file tolerated (returns 0)",
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 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": [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021",
"decision": "duplicate wrong birthdate", "correctDob": "02.04.2021", "source": "test"},
]}, open(dec2, "w", encoding="utf-8"))
raw = [
{"name": "Chelsea von den Kleinen Chaoten", "dob": "15.10.2021"}, # the wrong-dob duplicate
{"name": "Chelsea von den Kleinen Chaoten", "dob": "02.04.2021"}, # canonical
{"name": "Other Animal", "dob": "01.01.2020"},
]
rn = e.apply_dob_remaps(raw, dec2)
check("correctDob remaps the wrong-dob record", raw[0]["dob"] == "02.04.2021")
check("correctDob leaves the canonical record alone", raw[1]["dob"] == "02.04.2021")
check("correctDob leaves unrelated records alone", raw[2]["dob"] == "01.01.2020")
check("apply_dob_remaps returns remap count", rn == 1)
check("after remap both Chelsea share one dedup identity (name+dob)",
e.norm_dob(raw[0]["dob"]) == e.norm_dob(raw[1]["dob"]))
check("missing decisions file tolerated for dob remaps (returns 0)",
e.apply_dob_remaps([], os.path.join(tempfile.gettempdir(), "nope.json")) == 0)
try: os.remove(dec2)
except OSError: pass
try: os.remove(dec_path) try: os.remove(dec_path)
except OSError: pass except OSError: pass
# --- "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"]}]))
# 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("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) --- # --- 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("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")) check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. Welby v.d. Kleinen Chaoten"))