Compare commits
17 Commits
feature/ge
...
feature/ux
| Author | SHA1 | Date | |
|---|---|---|---|
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| 13eb17b453 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| db85a6e0dc | |||
| 0c94cfcbf1 | |||
| 5eadd89bb6 | |||
| f9a68deb7a | |||
| a693095886 | |||
| d5544412bd | |||
| d93e8d1586 | |||
| 114bbd92c8 | |||
| a8d8ae0dfc | |||
| 522f2eec51 | |||
| 3f71d8e28e |
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal file
69
GerbilManager.Tests/CmsPreviewTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,6 +321,230 @@ 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||||
|
{
|
||||||
|
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||||
|
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// One dated litter, one undated litter (blank date field)
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||||
|
[
|
||||||
|
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||||
|
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||||
|
|
||||||
|
using var db = NewDb();
|
||||||
|
|
||||||
|
// First run
|
||||||
|
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(2, r1.Litters.InSource);
|
||||||
|
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||||
|
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||||
|
|
||||||
|
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||||
|
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||||
|
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||||
|
}
|
||||||
|
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)]
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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, int WithoutDate = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
|
|||||||
@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
int littersCreated = 0, littersExisting = 0;
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
|
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||||
|
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||||
|
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||||
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
{
|
{
|
||||||
var date = ParseDate(sl.Date);
|
var date = ParseDate(sl.Date);
|
||||||
|
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||||
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
littersCreated++;
|
littersCreated++;
|
||||||
if (execute && date is DateOnly d)
|
if (execute)
|
||||||
{
|
{
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Name = name,
|
||||||
Date = d,
|
Date = date.Value,
|
||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8 && date is not null)
|
if (samples.Count < 8)
|
||||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
@@ -399,10 +405,69 @@ 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (littersWithoutDate > 0)
|
||||||
|
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||||
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 +476,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, littersWithoutDate),
|
||||||
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),
|
||||||
|
|||||||
@@ -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 D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
| 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`.
|
||||||
|
|
||||||
@@ -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 | **C2–C6** (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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||||
*/
|
*/
|
||||||
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const ta = de.pages.anfragen
|
const ta = de.pages.anfragen
|
||||||
const td = ta.detail
|
const td = ta.detail
|
||||||
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
|
|||||||
// Status-Badge auf der Karte
|
// Status-Badge auf der Karte
|
||||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Filter: nur Beantwortet
|
// Filter: nur Beantwortet
|
||||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||||
await expect(cards).toHaveCount(1)
|
await expect(cards).toHaveCount(1)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
@@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
|
|||||||
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||||
|
|
||||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
|
|||||||
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||||
|
*
|
||||||
|
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||||
|
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
|
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||||
|
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Toggle-Button sichtbar.
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
|
||||||
|
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
|
||||||
|
// Öffnen → Status-Feld wird sichtbar.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Schließen → wieder verborgen.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
|
||||||
|
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||||
|
await expect(toggle).toHaveText('Filter')
|
||||||
|
|
||||||
|
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||||
|
await toggle.click()
|
||||||
|
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||||
|
await expect(toggle).toHaveText('Filter (1)')
|
||||||
|
|
||||||
|
// Zurücksetzen → Badge weg.
|
||||||
|
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||||
|
await expect(toggle).toHaveText('Filter')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'desktop') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
await expect(toggle).toBeHidden()
|
||||||
|
|
||||||
|
// Status-Beschriftung direkt sichtbar.
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -63,6 +63,18 @@ export function acceptNextDialog(page: Page) {
|
|||||||
page.once('dialog', (d) => void d.accept())
|
page.once('dialog', (d) => void d.accept())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
|
||||||
|
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
|
||||||
|
* kein Klick nötig — Controls sind direkt sichtbar.
|
||||||
|
*/
|
||||||
|
export async function openFilterPanel(page: Page) {
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
if (await toggle.isVisible()) {
|
||||||
|
await toggle.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||||
export const uniqueName = (prefix: string) =>
|
export const uniqueName = (prefix: string) =>
|
||||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
const tabs = de.pages.tierTabs
|
const tabs = de.pages.tierTabs
|
||||||
@@ -23,6 +23,8 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
|
|||||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||||
await page
|
await page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||||
|
|||||||
66
gerbil-manager-web/e2e/webseite-vorschau.spec.ts
Normal file
66
gerbil-manager-web/e2e/webseite-vorschau.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import './filterPanel.css'
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
/** Always visible on mobile (typically the search text input). Optional. */
|
||||||
|
searchField?: ReactNode
|
||||||
|
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
|
||||||
|
children: ReactNode
|
||||||
|
/** Number of currently active (non-default) filter values. Shown as badge. */
|
||||||
|
activeCount: number
|
||||||
|
/** Called when the reset button is clicked. */
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
|
||||||
|
* Render inside the existing `.filters` div (or replace it entirely).
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): renders all children inline, identical to today.
|
||||||
|
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
|
||||||
|
* the rest of the controls in a column drawer + a reset button.
|
||||||
|
*/
|
||||||
|
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const t = de.filterPanel
|
||||||
|
|
||||||
|
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
|
||||||
|
{searchField}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost filter-panel__toggle"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div className="filter-panel__drawer">
|
||||||
|
{children}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
|
||||||
|
{t.resetButton}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
|
||||||
|
* children participate directly in the parent .filters flex row.
|
||||||
|
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
|
||||||
|
* width drawer (flex column) with the remaining filters + reset button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Mobile default ── */
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel--open .filter-panel__drawer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ── */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
/* Let children participate directly in the parent flex row. */
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset button sits in the flex row on desktop when active filters exist. */
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile reset button ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../api/requests'
|
} from '../api/requests'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDateTime } from '../format/labels'
|
import { formatDateTime } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './anfragen.css'
|
import './anfragen.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
|
|||||||
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||||
|
|
||||||
{/* Status-Filter */}
|
{/* Status-Filter */}
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={status !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setStatus(''); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.detail.statusLabel}</span>
|
<span>{t.detail.statusLabel}</span>
|
||||||
<select
|
<select
|
||||||
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{requests.error && (
|
{requests.error && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './gerbils.css'
|
import './gerbils.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
const items = gerbils.data?.items ?? []
|
const items = gerbils.data?.items ?? []
|
||||||
|
|
||||||
|
// UX-MOBILE-1: count non-default filter values for the badge.
|
||||||
|
const activeFilterCount =
|
||||||
|
(status !== 'Active' ? 1 : 0) +
|
||||||
|
(gender !== '' ? 1 : 0) +
|
||||||
|
(colorVarietyId !== '' ? 1 : 0) +
|
||||||
|
(originBreeder !== '' ? 1 : 0) +
|
||||||
|
(showExternal ? 1 : 0)
|
||||||
|
|
||||||
// Multi-select bulk "Zur Abgabe stellen".
|
// Multi-select bulk "Zur Abgabe stellen".
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const toggleSelect = (id: string) =>
|
const toggleSelect = (id: string) =>
|
||||||
@@ -118,15 +127,20 @@ export default function GerbilsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
<input
|
searchField={
|
||||||
type="search"
|
<input
|
||||||
className="input"
|
type="search"
|
||||||
placeholder={t.searchPlaceholder}
|
className="input"
|
||||||
value={search}
|
placeholder={t.searchPlaceholder}
|
||||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
value={search}
|
||||||
aria-label={t.fields.name}
|
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||||
/>
|
aria-label={t.fields.name}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
activeCount={activeFilterCount}
|
||||||
|
onReset={resetFilters}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filters.status}</span>
|
<span>{t.filters.status}</span>
|
||||||
<select
|
<select
|
||||||
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
|
|||||||
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn" onClick={resetFilters}>
|
</FilterPanel>
|
||||||
{t.filters.reset}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{gerbils.error && (
|
{gerbils.error && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
121
gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx
Normal file
121
gerbil-manager-web/src/pages/WebseiteVorschauPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import type { Litter } from '../api/types'
|
import type { Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate } from '../format/labels'
|
import { formatDate } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
|
|||||||
|
|
||||||
{tab === 'litters' && (
|
{tab === 'litters' && (
|
||||||
<>
|
<>
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={year !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filterYear}</span>
|
<span>{t.filterYear}</span>
|
||||||
<select
|
<select
|
||||||
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
|
|||||||
<option value="dateAsc">{t.sort.dateAsc}</option>
|
<option value="dateAsc">{t.sort.dateAsc}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{litters.error && (
|
{litters.error && (
|
||||||
|
|||||||
37
gerbil-manager-web/src/pages/webseiteVorschau.css
Normal file
37
gerbil-manager-web/src/pages/webseiteVorschau.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
@@ -791,6 +807,12 @@ export const de = {
|
|||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
|
filterPanel: {
|
||||||
|
toggleButton: 'Filter',
|
||||||
|
resetButton: 'Filter zurücksetzen',
|
||||||
|
closeButton: 'Schließen',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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: **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,23 +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 |
|
|
||||||
| 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 |
|
|
||||||
| 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 |
|
|
||||||
| 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 +87,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)
|
||||||
@@ -140,15 +126,15 @@ 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) |
|
||||||
| `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 | ? |
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user