Compare commits

...

10 Commits

Author SHA1 Message Date
efce79b3fa GEN-3g: CP colourpoint het-naming rule — '-Hell' == cchm/ch (breeder C9 answer)
- colourpointName(): A- cchm/ch now yields 'CP-<base>-Hell' (was 'CP-<base>');
  A- cchm/cchm unchanged. Non-agouti branch (Marder/Siam/Zobel/Zobel-Hell)
  unchanged — already used the het token.
- Added 5 new catalog entries: CP-Agouti-Hell, CP-Silberagouti-Hell,
  CP-Algierfuchs-Hell, CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.
  CP-Fuchs-Hell was already present. Catalog 61 -> 66.
- Regenerated colorVarietySeed.generated.json (66 rows for Pam DATA-2 re-seed).
- Tests: 82 -> 82 (reworded het case) + 1 new GEN-3g describe block. All pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:14:23 +02:00
13eb17b453 docs(HUMANQUESTION): C6-Status praezisiert - 27 von 32 Konflikten geklaert, nur D6 (5 Tiere) offen; review-report nach IMPORT-POLISH-Re-Extract (Konflikte 5)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 51s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m32s
CI / Docker Build & Push (push) Failing after 9s
2026-06-06 15:06:20 +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
d5544412bd WEB-3: lokale Webseiten-Vorschau - GET /api/preview/{**path} (rendert live, korrekte Content-Types, nur Veroeffentlichte) + /webseite/vorschau (iframe, Seiten-Wechsler, Smartphone/Desktop-Umschalter, Neu laden) + Einstiege auf der Uebersicht + Fix: pages.ts rief /pages statt /api/pages (live-404, vom Mock kaschiert) + 1 Backend-Roundtrip + 4 e2e-Specs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 12:18:07 +02:00
23 changed files with 950 additions and 149 deletions

View File

@@ -0,0 +1,69 @@
using System.Net;
using System.Net.Http.Json;
namespace GerbilManager.Tests;
/// <summary>
/// WEB-3: Round-Trips für die lokale Webseiten-Vorschau (GET /api/preview/…):
/// veröffentlichte Seiten werden als HTML ausgeliefert, Entwürfe nicht,
/// styles.css kommt mit CSS-Content-Type.
/// </summary>
public class CmsPreviewTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public CmsPreviewTests(ApiFactory factory) => _factory = factory;
private static object PageInput(string slug, string title, string status) =>
new { slug, title, seoDescription = (string?)null, status };
private sealed record PageRow(Guid Id, string Slug);
/// <summary>Seite anlegen oder (falls von WEB-0b geseedet) auf den Zielzustand setzen.</summary>
private static async Task UpsertPageAsync(HttpClient client, string slug, string title, string status)
{
var existing = (await client.GetFromJsonAsync<List<PageRow>>("/api/pages"))!
.FirstOrDefault(p => p.Slug == slug);
if (existing is null)
{
var created = await client.PostAsJsonAsync("/api/pages", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
}
else
{
var updated = await client.PutAsJsonAsync($"/api/pages/{existing.Id}", PageInput(slug, title, status));
Assert.Equal(HttpStatusCode.NoContent, updated.StatusCode);
}
}
[Fact]
public async Task Vorschau_LiefertVeroeffentlichteSeiteAlsHtml_UndKeineEntwuerfe()
{
var client = _factory.CreateClient();
await UpsertPageAsync(client, "start", "Willkommen bei der Zucht", "Published");
await UpsertPageAsync(client, "e2e-entwurf", "Unsere Zucht", "Draft");
// Startseite: /api/preview/ → index.html (text/html, enthält den Titel)
var index = await client.GetAsync("/api/preview/");
Assert.Equal(HttpStatusCode.OK, index.StatusCode);
Assert.StartsWith("text/html", index.Content.Headers.ContentType!.ToString());
Assert.Contains("Willkommen bei der Zucht", await index.Content.ReadAsStringAsync());
// Expliziter Pfad funktioniert ebenso
var explicitIndex = await client.GetAsync("/api/preview/index.html");
Assert.Equal(HttpStatusCode.OK, explicitIndex.StatusCode);
// CSS mit korrektem Content-Type (Renderer: SiteRenderer.CssPath)
var css = await client.GetAsync("/api/preview/assets/site.css");
Assert.Equal(HttpStatusCode.OK, css.StatusCode);
Assert.StartsWith("text/css", css.Content.Headers.ContentType!.ToString());
// Entwurf wird NICHT gerendert (Vorschau zeigt nur Veröffentlichtes)
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/index.html")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/e2e-entwurf/")).StatusCode);
// Unsinnige Pfade → 404
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/preview/gibt-es-nicht.html")).StatusCode);
}
}

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

@@ -32,6 +32,24 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList()); return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
}); });
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im
// Vorschau-iframe genauso wie später auf der echten Webseite. ----
api.MapGet("/preview/{**path}", async (string? path, ApplicationContext db) =>
{
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
var key = string.IsNullOrWhiteSpace(path) ? "index.html" : path.TrimEnd('/');
if (!files.TryGetValue(key, out var content) &&
!files.TryGetValue($"{key}/index.html", out content))
{
return Results.NotFound();
}
return Results.Content(content, PreviewContentType(key));
});
// ---- pages ---- // ---- pages ----
api.MapGet("/pages", async (ApplicationContext db) => api.MapGet("/pages", async (ApplicationContext db) =>
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug) TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
@@ -151,6 +169,13 @@ namespace GerbilManagerWebAPI.Endpoints
return app; return app;
} }
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
: path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ? "application/xml; charset=utf-8"
: path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ? "text/plain; charset=utf-8"
: "text/html; charset=utf-8";
private static BlockDto ToBlockDto(Block b) => private static BlockDto ToBlockDto(Block b) =>
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data)); new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));

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),

View File

@@ -49,10 +49,10 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt | | ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet | | ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt | | ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen | | C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen | | C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt | | ~~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`.
@@ -106,7 +108,7 @@ 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) | ✅ **DD** — Julian | | 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]****Mutter von „C"!** | ✅ **ee[f]** (`Aa CC D- ee[f] Gg pp Spsp [DP]`) — Julian → C bekommt damit seine Mutter | | 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

@@ -135,6 +135,29 @@ export async function installMockApi(page: Page): Promise<MockDb> {
if (method === 'PUT') return json(route, 204) if (method === 'PUT') return json(route, 204)
} }
// ── WEB-3: lokale Vorschau (Renderer-Dateien; nur veröffentlichte Seiten) ──
if (path === '/render-site' && method === 'GET') {
const files = db.pages
.filter((p) => p.status === 'Published')
.map((p) => ({ path: p.slug === 'start' ? 'index.html' : `${p.slug}/index.html`, size: 1000 }))
return json(route, 200, [{ path: 'assets/site.css', size: 500 }, ...files])
}
const pv = path.match(/^\/preview(?:\/(.*))?$/)
if (pv && method === 'GET') {
const key = pv[1] ? pv[1].replace(/\/$/, '') : 'index.html'
if (key === 'assets/site.css') {
return route.fulfill({ status: 200, contentType: 'text/css', body: 'body{font-family:sans-serif}' })
}
const slug = key === 'index.html' ? 'start' : key.replace(/\/index\.html$/, '')
const p = db.pages.find((x) => x.slug === slug && x.status === 'Published')
if (!p) return json(route, 404, { title: 'Not Found' })
return route.fulfill({
status: 200,
contentType: 'text/html; charset=utf-8',
body: `<!doctype html><html lang="de"><head><meta charset="utf-8"><title>${p.title}</title></head><body><h1>${p.title}</h1><p>Vorschau-Mock</p></body></html>`,
})
}
// ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ── // ── INBOX-1: Anfragen-Posteingang (vor den generischen Kollektionen) ──
if (path === '/requests/sync' && method === 'POST') { if (path === '/requests/sync' && method === 'POST') {
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' }) return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })

View File

@@ -0,0 +1,66 @@
/**
* WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die
* gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/
* Desktop-Umschalter, Einstiege von der Übersicht.
* Mock-gebunden (Seed-Seiten) → skipUnlessMock.
*/
import { de, expect, skipUnlessMock, test } from './fixtures'
const tw = de.pages.webseite
const tv = tw.vorschau
test.describe('Webseiten-Vorschau', () => {
test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite')
await page.getByRole('link', { name: tv.button }).click()
await expect(page.getByRole('heading', { name: tv.title })).toBeVisible()
await expect(page.getByText(tv.intro)).toBeVisible()
// Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible()
})
test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite/vorschau')
const select = page.getByLabel(tv.pageSelect)
// 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen
await expect(select.locator('option')).toHaveCount(2)
await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0)
await select.selectOption({ label: 'Abgabetiere' })
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
})
test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite/vorschau')
// Standard: Smartphone-Rahmen
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
await page.getByRole('button', { name: tv.viewDesktop }).click()
await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0)
await page.getByRole('button', { name: tv.viewPhone }).click()
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
})
test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => {
skipUnlessMock()
await page.goto('/webseite')
// Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht
const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' })
const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' })
await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible()
await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0)
await abgabeCard.getByRole('link', { name: tv.openPage }).click()
await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/)
const frame = page.frameLocator('.vorschau-iframe')
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
})
})

View File

@@ -24,6 +24,7 @@ import VertragWizardPage from './pages/VertragWizardPage'
import EinstellungenPage from './pages/EinstellungenPage' import EinstellungenPage from './pages/EinstellungenPage'
import WebseitePage from './pages/WebseitePage' import WebseitePage from './pages/WebseitePage'
import WebseiteEditorPage from './pages/WebseiteEditorPage' import WebseiteEditorPage from './pages/WebseiteEditorPage'
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
import AnfragenPage from './pages/AnfragenPage' import AnfragenPage from './pages/AnfragenPage'
import AnfrageDetailPage from './pages/AnfrageDetailPage' import AnfrageDetailPage from './pages/AnfrageDetailPage'
@@ -72,6 +73,8 @@ export default function App() {
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */} {/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
<Route path="webseite"> <Route path="webseite">
<Route index element={<WebseitePage />} /> <Route index element={<WebseitePage />} />
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
<Route path="vorschau" element={<WebseiteVorschauPage />} />
<Route path=":slug" element={<WebseiteEditorPage />} /> <Route path=":slug" element={<WebseiteEditorPage />} />
</Route> </Route>
{/* INBOX-1: Anfragen-Posteingang */} {/* INBOX-1: Anfragen-Posteingang */}

View File

@@ -14,7 +14,15 @@
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein * ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten). * typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
*/ */
import { api } from './client' import { API_BASE_URL, api } from './client'
/**
* WEB-3-Fix: Die CMS-Endpunkte liegen unter der /api-Gruppe
* (CmsEndpoints: MapGroup("/api")) — die Aufrufe hier liefen vorher gegen
* /pages und wären gegen die ECHTE API 404 gelaufen (der e2e-Mock hat den
* Unterschied kaschiert, weil er das /api-Präfix normalisiert).
*/
const CMS = '/api'
export type PageStatus = 'Draft' | 'Published' export type PageStatus = 'Draft' | 'Published'
@@ -121,29 +129,39 @@ export function defaultBlockData(type: BlockType): BlockData {
// ── API-Aufrufe ────────────────────────────────────────────────────────────── // ── API-Aufrufe ──────────────────────────────────────────────────────────────
export function listPages(): Promise<PageSummary[]> { export function listPages(): Promise<PageSummary[]> {
return api.get<PageSummary[]>('/pages') return api.get<PageSummary[]>(`${CMS}/pages`)
} }
export function getPage(slug: string): Promise<Page> { export function getPage(slug: string): Promise<Page> {
return api.get<Page>(`/pages/${encodeURIComponent(slug)}`) return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
} }
export function updatePage(id: string, input: PageInput): Promise<void> { export function updatePage(id: string, input: PageInput): Promise<void> {
return api.put<void>(`/pages/${id}`, input) return api.put<void>(`${CMS}/pages/${id}`, input)
} }
export function addBlock(pageId: string, input: BlockInput): Promise<Block> { export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
return api.post<Block>(`/pages/${pageId}/blocks`, input) return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
} }
export function updateBlock(id: string, input: BlockInput): Promise<void> { export function updateBlock(id: string, input: BlockInput): Promise<void> {
return api.put<void>(`/blocks/${id}`, input) return api.put<void>(`${CMS}/blocks/${id}`, input)
} }
export function deleteBlock(id: string): Promise<void> { export function deleteBlock(id: string): Promise<void> {
return api.delete(`/blocks/${id}`) return api.delete(`${CMS}/blocks/${id}`)
} }
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> { export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
return api.put<void>(`/pages/${pageId}/blocks/order`, { blockIds }) return api.put<void>(`${CMS}/pages/${pageId}/blocks/order`, { blockIds })
}
/**
* WEB-3: Absolute URL der lokalen Vorschau (GET /api/preview/… rendert live).
* "start" liegt auf index.html, alle anderen Seiten auf {slug}/index.html —
* dieselbe Abbildung wie im SiteRenderer.
*/
export function previewUrl(slug?: string | null): string {
const path = !slug || slug === 'start' ? 'index.html' : `${encodeURIComponent(slug)}/index.html`
return `${API_BASE_URL}/api/preview/${path}`
} }

View File

@@ -179,10 +179,11 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag') expect(match.name).toBe('Unbekannter Farbschlag')
}) })
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => { it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named // GEN-3f: 73 -> 61 (cchm CP reconciliation).
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61. // GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
expect(CATALOG_SIZE).toBe(61) // giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
expect(CATALOG_SIZE).toBe(66)
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
@@ -414,8 +415,12 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti') expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
}) })
it('A- + cchm/ch -> CP-<base colour>', () => { it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti') expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
}) })
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => { it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -471,10 +476,50 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel') expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
}) })
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => { it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
const siam = BASE_COLORS.find((e) => e.name === 'Siam')! const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')! const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam') expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell') expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
})
})
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
it('all new -Hell het entries exist in catalog', () => {
for (const n of [
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
]) {
expect(has(n)).toBe(true)
}
})
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
})
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
}) })
}) })

View File

@@ -107,27 +107,29 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' }, { name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' }, { name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ── // ── GEN-3f/3g: c^chm colourpoint varieties ──
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the // GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant); // GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants // A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
// collapse (other loci irrelevant for the CP prefix). These names == the strings // CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
// in her live data (god: extract animals.json) so the re-import name-matches and // CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell, // fox CP has no dedicated base entry); kept for import name-match + hand-pick.
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' }, { name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' }, { name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' }, { name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' }, { name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' }, { name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' }, { name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' }, { name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } }, { name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } }, { name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } }, { name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } }, { name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
] ]
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag' export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
@@ -207,12 +209,14 @@ function baseColourFor(g: Genotype): string | null {
} }
/** /**
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative). * GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
* Returns the colourpoint name, or null when it doesn't apply (full C present, * Returns the colourpoint name, or null when it doesn't apply (full C present,
* or chch — which the base matcher names Hermelin/Himalaya, preserving both). * or chch — which the base matcher names Hermelin/Himalaya, preserving both).
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam * aa cchm/cchm -> Marder | aa cchm/ch -> Siam
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell * aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full) * A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
*/ */
function colourpointName(g: Genotype): string | null { function colourpointName(g: Genotype): string | null {
const c = resolvedPair(g, 'C') const c = resolvedPair(g, 'C')
@@ -227,9 +231,9 @@ function colourpointName(g: Genotype): string | null {
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell' if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam' return bothCchm ? 'Marder' : 'Siam'
} }
// A- colourpoint -> CP-<base colour>, base as if C were full. // A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] })) const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}` : null return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
} }
export function farbschlagFor(g: Genotype): FarbschlagMatch { export function farbschlagFor(g: Genotype): FarbschlagMatch {

View File

@@ -340,42 +340,67 @@
"sortOrder": 53, "sortOrder": 53,
"image": "agouti-cp.jpg" "image": "agouti-cp.jpg"
}, },
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"sortOrder": 54
},
{ {
"name": "CP-Silberagouti", "name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere", "canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 54, "sortOrder": 55,
"image": "silberagouti-cp.JPG" "image": "silberagouti-cp.JPG"
}, },
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"sortOrder": 56
},
{ {
"name": "CP-Algierfuchs", "name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere", "canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 55, "sortOrder": 57,
"image": "algierfuchs-cp.jpg" "image": "algierfuchs-cp.jpg"
}, },
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"sortOrder": 58
},
{ {
"name": "CP-Polarfuchs", "name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere", "canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 56, "sortOrder": 59,
"image": "polarfuchs-cp.jpg" "image": "polarfuchs-cp.jpg"
}, },
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"sortOrder": 60
},
{ {
"name": "CP-Fuchs", "name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere", "canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 57 "sortOrder": 61
}, },
{ {
"name": "CP-Fuchs-Hell", "name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere", "canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 58 "sortOrder": 62
}, },
{ {
"name": "CP-Blaufuchs", "name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere", "canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 59 "sortOrder": 63
}, },
{ {
"name": "CP-Orangeschimmel", "name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere", "canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 60 "sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"sortOrder": 65
} }
] ]

View File

@@ -41,6 +41,12 @@ export default function WebseitePage() {
<h2>{t.title}</h2> <h2>{t.title}</h2>
<p className="muted">{t.intro}</p> <p className="muted">{t.intro}</p>
</div> </div>
{/* WEB-3: lokale Vorschau der gerenderten Webseite */}
<div className="head-actions">
<Link to="/webseite/vorschau" className="btn btn--primary">
{t.vorschau.button}
</Link>
</div>
</header> </header>
{pages.data.length === 0 ? ( {pages.data.length === 0 ? (
@@ -53,6 +59,12 @@ export default function WebseitePage() {
<span className="gerbil-card__meta">/{p.slug}</span> <span className="gerbil-card__meta">/{p.slug}</span>
<span className="webseite-card__actions"> <span className="webseite-card__actions">
<StatusBadge status={p.status} /> <StatusBadge status={p.status} />
{/* WEB-3: veröffentlichte Seiten direkt in der Vorschau öffnen */}
{p.status === 'Published' && (
<Link to={`/webseite/vorschau?seite=${encodeURIComponent(p.slug)}`} className="btn">
{t.vorschau.openPage}
</Link>
)}
<Link to={`/webseite/${p.slug}`} className="btn btn--primary"> <Link to={`/webseite/${p.slug}`} className="btn btn--primary">
{t.edit} {t.edit}
</Link> </Link>

View File

@@ -0,0 +1,121 @@
/**
* WEB-3: Lokale Vorschau der öffentlichen Webseite (/webseite/vorschau).
*
* Zeigt die vom Backend live gerenderte Seite (GET /api/preview/…) in einem
* iframe — exakt das HTML/CSS, das später veröffentlicht wird. Navigation
* INNERHALB der Vorschau funktioniert über die relativen Links der
* gerenderten Seite selbst; zusätzlich gibt es einen Seiten-Wechsler,
* einen Smartphone/Desktop-Umschalter und „Neu laden“.
* Nur veröffentlichte Seiten werden gerendert (SiteRenderer überspringt
* Entwürfe) — der Hinweis dazu steht über der Vorschau.
* Veröffentlichen selbst ist WEB-2 (gated) — hier gibt es bewusst keinen
* Publish-Knopf.
*/
import { useMemo, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { listPages, previewUrl } from '../api/pages'
import { useApi } from '../hooks/useApi'
import './webseiteVorschau.css'
type Viewport = 'phone' | 'desktop'
export default function WebseiteVorschauPage() {
const t = de.pages.webseite.vorschau
const [params] = useSearchParams()
const pages = useApi(() => listPages(), [])
const published = useMemo(
() => (pages.data ?? []).filter((p) => p.status === 'Published'),
[pages.data],
)
const requested = params.get('seite')
const [selected, setSelected] = useState<string | null>(requested)
const slug =
(selected && published.some((p) => p.slug === selected) ? selected : null) ??
(published.some((p) => p.slug === 'start') ? 'start' : (published[0]?.slug ?? null))
const [viewport, setViewport] = useState<Viewport>('phone')
const [reloadKey, setReloadKey] = useState(0)
if (pages.loading) return <p className="muted">{de.common.loading}</p>
if (pages.error) {
return (
<section className="page">
<h2>{t.title}</h2>
<div className="alert alert--error">
<span>{pages.error}</span>
<button type="button" className="btn" onClick={pages.reload}>
{de.common.retry}
</button>
</div>
</section>
)
}
return (
<section className="page vorschau-page">
<header className="page-head">
<div>
<h2>{t.title}</h2>
<p className="muted">{t.intro}</p>
</div>
<div className="head-actions">
<Link to="/webseite" className="btn">
{de.pages.webseite.back}
</Link>
</div>
</header>
{slug === null ? (
<p className="muted">{t.empty}</p>
) : (
<>
<div className="vorschau-toolbar">
<label className="field">
<span>{t.pageSelect}</span>
<select value={slug} onChange={(e) => setSelected(e.target.value)}>
{published.map((p) => (
<option key={p.id} value={p.slug}>
{p.title}
</option>
))}
</select>
</label>
<div className="vorschau-viewports" role="group" aria-label={`${t.viewPhone} / ${t.viewDesktop}`}>
<button
type="button"
className={viewport === 'phone' ? 'btn btn--primary' : 'btn'}
onClick={() => setViewport('phone')}
>
{t.viewPhone}
</button>
<button
type="button"
className={viewport === 'desktop' ? 'btn btn--primary' : 'btn'}
onClick={() => setViewport('desktop')}
>
{t.viewDesktop}
</button>
</div>
<button type="button" className="btn" onClick={() => setReloadKey((k) => k + 1)}>
{t.reload}
</button>
</div>
<div className={viewport === 'phone' ? 'vorschau-frame vorschau-frame--phone' : 'vorschau-frame'}>
<iframe
key={`${slug}:${reloadKey}`}
className="vorschau-iframe"
title={t.frameTitle}
src={previewUrl(slug)}
/>
</div>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,37 @@
/* WEB-3: Webseiten-Vorschau — seitenspezifische Stile
(Standing-Rule-Muster: eigene Datei statt index.css). */
.vorschau-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin: 0.75rem 0;
}
.vorschau-viewports {
display: flex;
gap: 0.35rem;
}
.vorschau-frame {
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background: var(--color-surface);
overflow: hidden;
height: min(70dvh, 50rem);
}
/* Smartphone-Ansicht: schmaler Rahmen, mittig — wie ein Handy auf dem Tisch. */
.vorschau-frame--phone {
max-width: 400px;
margin-inline: auto;
}
.vorschau-iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}

View File

@@ -655,6 +655,22 @@ export const de = {
statusPublished: 'Veröffentlicht', statusPublished: 'Veröffentlicht',
edit: 'Bearbeiten', edit: 'Bearbeiten',
back: 'Zurück zur Übersicht', back: 'Zurück zur Übersicht',
// ── WEB-3 (Kelly): lokale Vorschau ──
vorschau: {
button: 'Vorschau',
title: 'Vorschau der Webseite',
intro:
'So sieht deine Webseite nach dem Veröffentlichen aus. Entwürfe erscheinen hier noch nicht.',
pageSelect: 'Seite',
reload: 'Neu laden',
viewPhone: 'Smartphone',
viewDesktop: 'Desktop',
frameTitle: 'Vorschau der öffentlichen Webseite',
empty:
'Noch keine veröffentlichte Seite — stelle eine Seite auf „Veröffentlicht“, um die Vorschau zu sehen.',
draftHint: 'Entwurf — erscheint noch nicht in der Vorschau.',
openPage: 'Ansehen',
},
// Seiten-Editor (Kopf) // Seiten-Editor (Kopf)
editor: { editor: {
pageTitleLabel: 'Seitentitel', pageTitleLabel: 'Seitentitel',

View File

@@ -79,9 +79,9 @@
"source": "Julian 2026-06-06 — HUMANQUESTION D4" "source": "Julian 2026-06-06 — HUMANQUESTION D4"
}, },
{ {
"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
"dob": "16.01.2023", "dob": "16.01.2023",
"decision": "E-locus = ee[f] (Fuchs). NOTE: this is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent.", "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]", "genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
"source": "Julian 2026-06-06 — HUMANQUESTION D4" "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 "",
@@ -518,10 +522,11 @@ def _alleles_compatible(a, b):
if a == b: if a == b:
return True return True
if a == "?" or b == "?": if a == "?" or b == "?":
return False # unknown vs filled = contradiction (D- vs DD) return True # specific-wins: unknown allele is compatible with any
# specified value (C- vs CC -> CC; G- vs Gg -> Gg)
(ba, ma), (bb, mb) = _split_allele(a), _split_allele(b) (ba, ma), (bb, mb) = _split_allele(a), _split_allele(b)
if ba != bb: if ba != bb:
return False # different base allele = real value diff (E vs e) return False # different base allele = real value diff (E vs e, D vs d)
return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins return ma == "" or mb == "" # same base, modifier present-vs-absent -> presence wins
@@ -622,9 +627,12 @@ def dedup(animals):
if a.get("deaf") is not None: 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"],
@@ -887,21 +895,30 @@ def apply_dob_remaps(raw_animals, path):
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a """PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match = canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
norm_name(name)+norm_dob(dob). Tolerates a missing/garbled file. Returns the remap count. canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
Tolerates a missing/garbled file. Returns the remap count.
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)""" Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
remaps = {} remaps_full = {} # (nameCanon, zuchtCanon, dob) -> correctDob — decision carries Zucht
remaps_name = {} # (nameCanon, dob) -> correctDob — no Zucht in decision
try: 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 []):
if r.get("correctDob"): if r.get("correctDob"):
remaps[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r["correctDob"] nc, zc = canon_pair(r.get("name", ""))
dob = norm_dob(r.get("dob", ""))
if zc:
remaps_full[(nc, zc, dob)] = r["correctDob"]
else:
remaps_name[(nc, dob)] = r["correctDob"]
except (OSError, ValueError): except (OSError, ValueError):
return 0 return 0
if not remaps: if not remaps_full and not remaps_name:
return 0 return 0
n = 0 n = 0
for a in raw_animals: for a in raw_animals:
new = remaps.get((norm_name(a.get("name", "")), norm_dob(a.get("dob", "")))) nc, zc = canon_pair(a.get("name", ""))
dob = norm_dob(a.get("dob", ""))
new = remaps_full.get((nc, zc, dob)) or remaps_name.get((nc, dob))
if new and a.get("dob") != new: if new and a.get("dob") != new:
a["dob"] = new a["dob"] = new
n += 1 n += 1
@@ -911,23 +928,36 @@ def apply_dob_remaps(raw_animals, path):
def apply_conflict_decisions(merged, conflicts, path): def apply_conflict_decisions(merged, conflicts, path):
"""Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's """Consume human conflict resolutions (tools/import/conflict-decisions.json) so the wife's
answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?, answers UN-QUARANTINE animals. Schema: {"resolutions":[{name, dob, decision, genotype?,
farbschlag?, source}]}. Match = norm_name(name)+norm_dob(dob) (same identity as dedup). A farbschlag?, source}]}. Match = canon_pair(name)+(dob):
matching animal: clear its conflict, mark resolvedByDecision; an explicit `genotype` - When the decision name CARRIES a Zucht (zuchtCanon != ''), match on the FULL
(breeder notation) is parsed and becomes authoritative, `farbschlag` overrides too. Tolerates (nameCanon, zuchtCanon, dob) triple — preserves the C3 rule that same name+DOB but
a missing/empty/garbled file. Returns the number of conflicts resolved. (god/HUMANQUESTION D.)""" different Zucht = different animal.
decisions = {} - 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: 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 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): except (OSError, ValueError):
return 0 return 0
if not decisions: 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

View File

@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
- Nach Zusammenführung (eindeutige Tiere): **621** - Nach Zusammenführung (eindeutige Tiere): **621**
- davon mit Geburtsdatum: 326 - davon mit Geburtsdatum: 326
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
- Konflikte zur Klärung: **9** - Konflikte zur Klärung: **5**
- 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**
@@ -25,13 +25,9 @@ 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 |
| 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 |
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids | | Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids | | 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 |
| 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 |
| 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 |
@@ -130,7 +126,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| Token | Vorkommen | Bedeutung (Vermutung) | | Token | Vorkommen | Bedeutung (Vermutung) |
|---|---|---| |---|---|---|
| `/+` | 7 | ? | | `/+` | 6 | ? |
| `-g` | 2 | ? | | `-g` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) | | `C(C)` | 2 | Schreibweise (C trägt c) |
| `Cc[]` | 1 | ? | | `Cc[]` | 1 | ? |

View File

@@ -102,6 +102,63 @@ 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 --- # --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json") dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
_json.dump({"resolutions": [ _json.dump({"resolutions": [
@@ -128,23 +185,95 @@ except OSError: pass
try: os.remove(dec_path) try: os.remove(dec_path)
except OSError: pass except OSError: pass
# --- "presence wins" conflict rule (Julian) --- # --- "presence wins" + "specific wins" conflict rules (Julian) ---
# present-vs-absent (whole locus or [f] modifier) is NOT a conflict; differing filled values are. # 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", check("spsp present vs locus absent -> no conflict",
not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}])) not e._genotype_conflict([{"Sp": ["sp", "sp"]}, {}]))
check("ee[f] vs ee ([f] modifier present/absent) -> no conflict", check("ee[f] vs ee ([f] modifier present/absent) -> no conflict",
not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}])) not e._genotype_conflict([{"E": ["e", "e^f"]}, {"E": ["e", "e"]}]))
check("DD vs D- (unknown vs filled) -> conflict", # FIX-2: '?' vs specified = specific wins (was: contradiction)
e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "?"]}])) 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", check("Ee vs ee (different base allele) -> conflict",
e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}])) e._genotype_conflict([{"E": ["E", "e"]}, {"E": ["e", "e"]}]))
check("C- vs Cc[h] -> conflict", check("DD vs Dd (both specified, D vs d) -> conflict",
e._genotype_conflict([{"C": ["C", "?"]}, {"C": ["C", "c^h"]}])) e._genotype_conflict([{"D": ["D", "D"]}, {"D": ["D", "d"]}]))
check("c[h] vs c[chm] (different modifiers) -> conflict", 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")) not e._alleles_compatible("c^h", "c^chm"))
check("identical genotypes -> no conflict", check("identical genotypes -> no conflict",
not e._genotype_conflict([{"A": ["A", "a"]}, {"A": ["A", "a"]}])) 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"))