Compare commits
41 Commits
feature/ge
...
feature/wu
| Author | SHA1 | Date | |
|---|---|---|---|
| f6bfc0ebab | |||
| 3160b66697 | |||
| 39767cef0f | |||
| c26ac5c6dd | |||
| 2c3f342905 | |||
| b165916dfb | |||
| 797b38df34 | |||
| c34d63e0fd | |||
| a2bdf9626c | |||
| 0d4b560df8 | |||
| d116b8d8f1 | |||
| 8dfad32918 | |||
| 1ee176a24a | |||
| a2f5e848d1 | |||
| de293904bb | |||
| 68447896ee | |||
| 5f26594a12 | |||
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| c357a18418 | |||
| 791e9f91cc | |||
| 97acaf6b10 | |||
| efce79b3fa | |||
| 868c5f5cd7 | |||
| ddfa3614a4 | |||
| 13da8f2502 | |||
| 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,21 @@ public class CmsTests : IClassFixture<ApiFactory>
|
|||||||
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
public CmsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Snapshot_has_site_nav_and_six_seeded_pages()
|
public async Task Snapshot_has_site_nav_and_seeded_pages()
|
||||||
{
|
{
|
||||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
Assert.Equal("de", root.GetProperty("site").GetProperty("defaultLocale").GetString());
|
||||||
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
var nav = root.GetProperty("site").GetProperty("navOrder").EnumerateArray().Select(x => x.GetString()).ToList();
|
||||||
|
// main nav = 6 core pages (Impressum/Datenschutz are footer-only, not in navOrder)
|
||||||
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
Assert.Equal(new[] { "start", "ueber-die-zucht", "abgabetiere", "abgabebedingungen", "farben-genetik", "kontakt" }, nav);
|
||||||
|
|
||||||
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
var pages = root.GetProperty("pages").EnumerateArray().ToList();
|
||||||
Assert.Equal(6, pages.Count);
|
// 8 pages: 6 core + impressum + datenschutz (footer-only legal pages)
|
||||||
|
Assert.Equal(8, pages.Count);
|
||||||
|
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "impressum");
|
||||||
|
Assert.Contains(pages, p => p.GetProperty("slug").GetString() == "datenschutz");
|
||||||
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
// abgabetiere page carries an AbgabetiereList block with a resolved (possibly empty) animals array
|
||||||
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
var abg = pages.Single(p => p.GetProperty("slug").GetString() == "abgabetiere");
|
||||||
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
var listBlock = abg.GetProperty("blocks").EnumerateArray()
|
||||||
|
|||||||
@@ -321,6 +321,256 @@ 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 { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||||
|
{
|
||||||
|
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
|
||||||
|
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
|
||||||
|
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
|
||||||
|
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
|
||||||
|
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
|
||||||
|
|
||||||
|
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
|
||||||
|
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||||
|
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||||
|
Assert.NotNull(cpFuchs);
|
||||||
|
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||||
|
|
||||||
|
// New entries at IDs 62-66 exist with correct names
|
||||||
|
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
||||||
|
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
||||||
|
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
||||||
|
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
||||||
|
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
||||||
|
|
||||||
|
// Total count is exactly 66
|
||||||
|
Assert.Equal(66, db.ColorVarieties.Count());
|
||||||
|
}
|
||||||
|
|
||||||
[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)]
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||||
Assert.Contains("NIEMALS Preise", prompt);
|
Assert.Contains("NIEMALS Preise", prompt);
|
||||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||||
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
|
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
|
||||||
|
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
|
||||||
Assert.Contains("Status: FREI", prompt);
|
Assert.Contains("Status: FREI", prompt);
|
||||||
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
|
|
||||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,11 @@ public class SiteRendererTests
|
|||||||
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
["data"] = new JsonObject { ["name"] = name, ["email"] = email, ["phone"] = phone },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static JsonObject AbgabetiereBlock(string intro, params (string name, string farbe, string? group, string? photo)[] animals)
|
private static JsonObject AbgabetiereBlock(string intro,
|
||||||
|
params (string name, string farbe, string? group, string? photo, string? gender, string? dob, string? note)[] animals)
|
||||||
{
|
{
|
||||||
var animalArr = new JsonArray();
|
var animalArr = new JsonArray();
|
||||||
foreach (var (name, farbe, group, photo) in animals)
|
foreach (var (name, farbe, group, photo, gender, dob, note) in animals)
|
||||||
{
|
{
|
||||||
var photos = new JsonArray();
|
var photos = new JsonArray();
|
||||||
if (photo is not null) photos.Add(photo);
|
if (photo is not null) photos.Add(photo);
|
||||||
@@ -81,6 +82,9 @@ public class SiteRendererTests
|
|||||||
["name"] = name,
|
["name"] = name,
|
||||||
["farbschlag"] = farbe,
|
["farbschlag"] = farbe,
|
||||||
["group"] = group,
|
["group"] = group,
|
||||||
|
["gender"] = gender,
|
||||||
|
["dateOfBirth"] = dob,
|
||||||
|
["characterNote"] = note,
|
||||||
["photos"] = photos,
|
["photos"] = photos,
|
||||||
["aiSaleText"] = (JsonNode?)null,
|
["aiSaleText"] = (JsonNode?)null,
|
||||||
});
|
});
|
||||||
@@ -92,6 +96,7 @@ public class SiteRendererTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -211,16 +216,52 @@ public class SiteRendererTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
public void AbgabetiereList_renders_group_card_with_name_farbschlag_and_photo()
|
||||||
{
|
{
|
||||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg", null, null, null)))));
|
||||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
Assert.Contains("Krümel", html);
|
Assert.Contains("Krümel", html);
|
||||||
Assert.Contains("CP-Agouti", html);
|
Assert.Contains("CP-Agouti", html);
|
||||||
Assert.Contains("Großbecken", html);
|
Assert.Contains("Großbecken", html);
|
||||||
Assert.Contains("/photos/files/abc.jpg", html);
|
Assert.Contains("/photos/files/abc.jpg", html);
|
||||||
Assert.Contains("cms-animal-card", html);
|
Assert.Contains("cms-group-card", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgabetiereList_group_card_shows_gender_label_and_available_badge()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
|
Blocks(AbgabetiereBlock("",
|
||||||
|
("Frieda", "Agouti", "Becken 1", null, "female", null, null),
|
||||||
|
("Rosa", "PEW", "Becken 1", null, "female", null, null)))));
|
||||||
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
|
Assert.Contains("Weibchen", html);
|
||||||
|
Assert.Contains("Verfügbar", html);
|
||||||
|
Assert.Contains("cms-badge--free", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgabetiereList_shows_dateOfBirth_and_characterNote()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
|
Blocks(AbgabetiereBlock("",
|
||||||
|
("Pünktchen", "Siamesisch", null, null, "female", "2024-03-15", "Sehr neugierig und verspielt.")))));
|
||||||
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
|
Assert.Contains("März 2024", html);
|
||||||
|
Assert.Contains("Sehr neugierig und verspielt.", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbgabetiereList_solo_animal_without_group_gets_its_own_card()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||||
|
Blocks(AbgabetiereBlock("",
|
||||||
|
("Einzelkind", "Zobel", null, null, "male", null, null)))));
|
||||||
|
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||||
|
Assert.Contains("Einzelkind", html);
|
||||||
|
Assert.Contains("Männchen", html);
|
||||||
|
Assert.Contains("cms-group-card", html);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -283,6 +324,44 @@ public class SiteRendererTests
|
|||||||
Assert.Equal(7, files.Count);
|
Assert.Equal(7, files.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_impressum_and_datenschutz_pages_are_rendered()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(
|
||||||
|
("start", "Startseite", Blocks()),
|
||||||
|
("impressum", "Impressum", Blocks(RichTextBlock("§ 5 TMG Platzhalter"))),
|
||||||
|
("datenschutz", "Datenschutz", Blocks(RichTextBlock("Datenschutz Platzhalter"))));
|
||||||
|
var files = SiteRenderer.Render(snap);
|
||||||
|
Assert.True(files.ContainsKey("impressum/index.html"));
|
||||||
|
Assert.True(files.ContainsKey("datenschutz/index.html"));
|
||||||
|
Assert.Contains("§ 5 TMG Platzhalter", files["impressum/index.html"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Footer_contains_impressum_and_datenschutz_links()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(
|
||||||
|
("start", "Startseite", Blocks()),
|
||||||
|
("kontakt", "Kontakt", Blocks()));
|
||||||
|
var startHtml = SiteRenderer.Render(snap)["index.html"];
|
||||||
|
Assert.Contains("impressum/index.html", startHtml);
|
||||||
|
Assert.Contains("datenschutz/index.html", startHtml);
|
||||||
|
// links in footer, not nav
|
||||||
|
Assert.Contains("site-footer-link", startHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Footer_links_use_correct_relative_path_from_subpage()
|
||||||
|
{
|
||||||
|
var snap = MakeSnapshot(
|
||||||
|
("start", "Startseite", Blocks()),
|
||||||
|
("kontakt", "Kontakt", Blocks()));
|
||||||
|
var kontaktHtml = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||||
|
// subpage needs ../ prefix for footer links
|
||||||
|
Assert.Contains("../impressum/index.html", kontaktHtml);
|
||||||
|
Assert.Contains("../datenschutz/index.html", kontaktHtml);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ public class ApplicationContext : DbContext
|
|||||||
(6, "kontakt", "Kontakt"),
|
(6, "kontakt", "Kontakt"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// WEB-6a: footer-only legal pages (not in main nav)
|
||||||
|
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||||
|
{
|
||||||
|
(7, "impressum", "Impressum",
|
||||||
|
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||||
|
(8, "datenschutz", "Datenschutz",
|
||||||
|
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||||
|
};
|
||||||
|
|
||||||
var pageRows = new List<Page>();
|
var pageRows = new List<Page>();
|
||||||
var blockRows = new List<Block>();
|
var blockRows = new List<Block>();
|
||||||
foreach (var p in pages)
|
foreach (var p in pages)
|
||||||
@@ -211,6 +220,12 @@ public class ApplicationContext : DbContext
|
|||||||
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
Id = Bid(10), PageId = Pid(3), Order = 1, Type = BlockType.AbgabetiereList,
|
||||||
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
Data = "{\"mode\":\"auto\",\"intro\":\"\"}",
|
||||||
});
|
});
|
||||||
|
foreach (var lp in legalPages)
|
||||||
|
{
|
||||||
|
pageRows.Add(new Page { Id = Pid(lp.n), Slug = lp.Slug, Title = lp.Title, Status = PageStatus.Published });
|
||||||
|
blockRows.Add(new Block { Id = Bid(lp.n), PageId = Pid(lp.n), Order = 0, Type = BlockType.Heading, Data = $"{{\"text\":\"{lp.Title}\",\"level\":1}}" });
|
||||||
|
blockRows.Add(new Block { Id = Bid(lp.n * 10), PageId = Pid(lp.n), Order = 1, Type = BlockType.RichText, Data = $"{{\"markdown\":\"{lp.BodyMd}\"}}" });
|
||||||
|
}
|
||||||
|
|
||||||
modelBuilder.Entity<Page>().HasData(pageRows);
|
modelBuilder.Entity<Page>().HasData(pageRows);
|
||||||
modelBuilder.Entity<Block>().HasData(blockRows);
|
modelBuilder.Entity<Block>().HasData(blockRows);
|
||||||
@@ -231,6 +246,10 @@ public class ApplicationContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
|
||||||
|
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
|
||||||
|
// SortOrder for new entries is appended; Kevin's frontend catalog handles
|
||||||
|
// the interleaved display order via its own sortOrder values.
|
||||||
(string Name, string Genotype)[] catalog =
|
(string Name, string Genotype)[] catalog =
|
||||||
{
|
{
|
||||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
||||||
@@ -294,6 +313,12 @@ public class ApplicationContext : DbContext
|
|||||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
||||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
||||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
||||||
|
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
|
||||||
|
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
|
||||||
|
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
|
||||||
|
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
|
||||||
|
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
|
||||||
|
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
|
||||||
};
|
};
|
||||||
|
|
||||||
var rows = new ColorVariety[catalog.Length];
|
var rows = new ColorVariety[catalog.Length];
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
private static string RenderAbgabetiereList(JsonObject d)
|
private static string RenderAbgabetiereList(JsonObject d)
|
||||||
{
|
{
|
||||||
var intro = Str(d["intro"]);
|
var intro = Str(d["intro"]);
|
||||||
var animals = d["animals"] as JsonArray ?? [];
|
var animals = (d["animals"] as JsonArray ?? [])
|
||||||
|
.OfType<JsonObject>().ToList();
|
||||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(intro))
|
if (!string.IsNullOrWhiteSpace(intro))
|
||||||
@@ -140,39 +141,80 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
if (animals.Count == 0)
|
if (animals.Count == 0)
|
||||||
{
|
{
|
||||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||||
|
sb.Append("</section>\n");
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
// Group by Becken name; null group → solo card keyed by animal name
|
||||||
|
var groups = animals
|
||||||
|
.GroupBy(a => Str(a["group"]) ?? ("__solo__" + (Str(a["name"]) ?? "")))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
sb.Append(" <div class=\"cms-group-list\">\n");
|
||||||
|
foreach (var grp in groups)
|
||||||
{
|
{
|
||||||
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
var isSolo = grp.Key.StartsWith("__solo__", StringComparison.Ordinal);
|
||||||
foreach (var node in animals)
|
var groupName = isSolo ? null : grp.Key;
|
||||||
{
|
var memberList = grp.ToList();
|
||||||
if (node is not JsonObject a) continue;
|
|
||||||
var name = H(Str(a["name"]) ?? "");
|
|
||||||
var farbe = H(Str(a["farbschlag"]) ?? "");
|
|
||||||
var group = Str(a["group"]);
|
|
||||||
var saleText = Str(a["aiSaleText"]);
|
|
||||||
var photos = a["photos"] as JsonArray ?? [];
|
|
||||||
|
|
||||||
sb.Append(" <article class=\"cms-animal-card\">\n");
|
// Determine group gender label
|
||||||
|
var genders = memberList.Select(a => Str(a["gender"]) ?? "unknown").Distinct().ToList();
|
||||||
|
string genderLabel = genders.Count == 1
|
||||||
|
? genders[0] switch { "female" => "Weibchen", "male" => "Männchen", _ => "" }
|
||||||
|
: genders.Any(g => g != "unknown") ? "Gemischt" : "";
|
||||||
|
|
||||||
// Profile photo (first photo)
|
sb.Append(" <article class=\"cms-group-card\">\n");
|
||||||
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
sb.Append(" <div class=\"cms-group-header\">\n");
|
||||||
if (!string.IsNullOrEmpty(firstPhoto))
|
if (!string.IsNullOrEmpty(groupName))
|
||||||
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
sb.Append($" <h3 class=\"cms-group-name\">{H(groupName)}</h3>\n");
|
||||||
|
var metaParts = new List<string>();
|
||||||
sb.Append(" <div class=\"cms-animal-info\">\n");
|
if (!string.IsNullOrEmpty(genderLabel)) metaParts.Add(H(genderLabel));
|
||||||
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
metaParts.Add("<span class=\"cms-badge cms-badge--free\">Verfügbar</span>");
|
||||||
if (!string.IsNullOrEmpty(farbe))
|
sb.Append($" <span class=\"cms-group-meta\">{string.Join(" · ", metaParts)}</span>\n");
|
||||||
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
|
||||||
if (!string.IsNullOrWhiteSpace(group))
|
|
||||||
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
|
||||||
if (!string.IsNullOrWhiteSpace(saleText))
|
|
||||||
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
|
||||||
sb.Append(" </div>\n");
|
sb.Append(" </div>\n");
|
||||||
|
|
||||||
|
// Gallery: first photo of each animal in the group
|
||||||
|
var gallery = memberList
|
||||||
|
.Select(a => (
|
||||||
|
url: (a["photos"] as JsonArray ?? []).FirstOrDefault()?.GetValue<string>(),
|
||||||
|
name: Str(a["name"]) ?? ""))
|
||||||
|
.Where(x => !string.IsNullOrEmpty(x.url))
|
||||||
|
.ToList();
|
||||||
|
if (gallery.Count > 0)
|
||||||
|
{
|
||||||
|
sb.Append(" <div class=\"cms-group-gallery\">\n");
|
||||||
|
foreach (var (url, name) in gallery)
|
||||||
|
sb.Append($" <img src=\"{H(url)}\" alt=\"{H(name)}\" loading=\"lazy\" class=\"cms-group-photo\">\n");
|
||||||
|
sb.Append(" </div>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-animal detail rows
|
||||||
|
sb.Append(" <ul class=\"cms-animal-list\">\n");
|
||||||
|
foreach (var a in memberList)
|
||||||
|
{
|
||||||
|
var name = H(Str(a["name"]) ?? "");
|
||||||
|
var farbe = Str(a["farbschlag"]);
|
||||||
|
var dob = Str(a["dateOfBirth"]);
|
||||||
|
var note = Str(a["characterNote"]);
|
||||||
|
var saleText = Str(a["aiSaleText"]);
|
||||||
|
|
||||||
|
sb.Append(" <li class=\"cms-animal-row\">\n");
|
||||||
|
sb.Append($" <span class=\"cms-animal-name\">{name}</span>\n");
|
||||||
|
var details = new List<string>();
|
||||||
|
if (!string.IsNullOrEmpty(farbe)) details.Add(H(farbe));
|
||||||
|
if (!string.IsNullOrEmpty(dob) && DateOnly.TryParse(dob, out var dob2))
|
||||||
|
details.Add($"* {dob2.ToString("MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("de-DE"))}");
|
||||||
|
if (details.Count > 0)
|
||||||
|
sb.Append($" <span class=\"cms-animal-detail\">{string.Join(" · ", details)}</span>\n");
|
||||||
|
var personalityText = note ?? saleText;
|
||||||
|
if (!string.IsNullOrWhiteSpace(personalityText))
|
||||||
|
sb.Append($" <span class=\"cms-animal-note\">{H(personalityText)}</span>\n");
|
||||||
|
sb.Append(" </li>\n");
|
||||||
|
}
|
||||||
|
sb.Append(" </ul>\n");
|
||||||
sb.Append(" </article>\n");
|
sb.Append(" </article>\n");
|
||||||
}
|
}
|
||||||
sb.Append(" </div>\n");
|
sb.Append(" </div>\n");
|
||||||
}
|
|
||||||
|
|
||||||
sb.Append("</section>\n");
|
sb.Append("</section>\n");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -212,6 +254,10 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
</main>
|
</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||||
|
<nav class="site-footer-nav" aria-label="Rechtliches">
|
||||||
|
<a href="{navBase}impressum/index.html" class="site-footer-link">Impressum</a>
|
||||||
|
<a href="{navBase}datenschutz/index.html" class="site-footer-link">Datenschutz</a>
|
||||||
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -228,6 +274,8 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
["abgabebedingungen"] = "Abgabebedingungen",
|
["abgabebedingungen"] = "Abgabebedingungen",
|
||||||
["farben-genetik"] = "Farben & Genetik",
|
["farben-genetik"] = "Farben & Genetik",
|
||||||
["kontakt"] = "Kontakt",
|
["kontakt"] = "Kontakt",
|
||||||
|
["impressum"] = "Impressum",
|
||||||
|
["datenschutz"] = "Datenschutz",
|
||||||
};
|
};
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
@@ -336,121 +384,256 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
internal static string SiteCss() => """
|
internal static string SiteCss() => """
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
:root {
|
:root {
|
||||||
--color-bg: #fdf8f3;
|
--color-bg: #fdf7f0;
|
||||||
--color-surface: #fff;
|
--color-surface: #fff;
|
||||||
--color-text: #2c2c2c;
|
--color-surface-warm: #fef9f4;
|
||||||
--color-muted: #6b6b6b;
|
--color-text: #2a2118;
|
||||||
--color-accent: #c0392b;
|
--color-muted: #7a6d62;
|
||||||
--color-border: #e0d8d0;
|
--color-accent: #b5331a;
|
||||||
--font-body: system-ui, sans-serif;
|
--color-accent-hover: #8f2815;
|
||||||
--max-w: 860px;
|
--color-accent-soft: #fdf0ed;
|
||||||
|
--color-border: #e8ddd4;
|
||||||
|
--color-badge-free: #2d7a4a;
|
||||||
|
--color-badge-free-bg: #e6f4ec;
|
||||||
|
--font-body: Georgia, "Times New Roman", serif;
|
||||||
|
--font-ui: system-ui, -apple-system, sans-serif;
|
||||||
|
--max-w: 900px;
|
||||||
|
--radius: 10px;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
line-height: 1.65;
|
line-height: 1.7;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
a { color: var(--color-accent); text-decoration: none; }
|
a { color: var(--color-accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; color: var(--color-accent-hover); }
|
||||||
img { max-width: 100%; height: auto; display: block; }
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
|
p { margin-bottom: .75rem; }
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
.site-header {
|
.site-header {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 2px solid var(--color-border);
|
||||||
padding: .75rem 1rem;
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
align-items: stretch;
|
||||||
align-items: center;
|
gap: 0;
|
||||||
gap: .5rem 1.5rem;
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
.site-logo {
|
.site-logo {
|
||||||
|
font-family: var(--font-body);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.15rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
display: flex;
|
||||||
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
align-items: center;
|
||||||
.site-nav-link {
|
padding-right: 1.5rem;
|
||||||
font-size: .9rem;
|
border-right: 1px solid var(--color-border);
|
||||||
color: var(--color-muted);
|
|
||||||
padding: .2rem .4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.site-nav-link:hover, .site-nav-link[aria-current="page"] {
|
|
||||||
color: var(--color-accent);
|
|
||||||
background: #fef0ee;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.site-logo:hover { color: var(--color-accent); text-decoration: none; }
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding-left: .5rem;
|
||||||
|
}
|
||||||
|
.site-nav::-webkit-scrollbar { display: none; }
|
||||||
|
.site-nav-link {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
padding: .5rem .65rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.site-nav-link:hover { color: var(--color-accent); background: var(--color-accent-soft); text-decoration: none; }
|
||||||
|
.site-nav-link[aria-current="page"] {
|
||||||
|
color: var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main ── */
|
/* ── Main ── */
|
||||||
.site-main {
|
.site-main {
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
margin: 2rem auto;
|
margin: 2.5rem auto;
|
||||||
padding: 0 1rem 3rem;
|
padding: 0 1rem 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
/* ── Footer ── */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
border-top: 1px solid var(--color-border);
|
background: var(--color-surface);
|
||||||
padding: 1.5rem 1rem;
|
border-top: 2px solid var(--color-border);
|
||||||
|
padding: 1.75rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-family: var(--font-ui);
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
.site-footer-nav { margin-top: .6rem; display: flex; justify-content: center; gap: 1.25rem; }
|
||||||
|
.site-footer-link { color: var(--color-muted); font-size: .8rem; }
|
||||||
|
.site-footer-link:hover { color: var(--color-accent); }
|
||||||
|
|
||||||
/* ── CMS blocks ── */
|
/* ── CMS blocks ── */
|
||||||
.cms-heading { margin: 1.5rem 0 .5rem; }
|
.cms-heading { margin: 2rem 0 .6rem; line-height: 1.3; }
|
||||||
h1.cms-heading { font-size: 1.8rem; }
|
h1.cms-heading { font-size: 2rem; margin-top: 0; }
|
||||||
h2.cms-heading { font-size: 1.4rem; }
|
h2.cms-heading { font-size: 1.5rem; }
|
||||||
|
h3.cms-heading { font-size: 1.2rem; }
|
||||||
.cms-richtext { margin: 1rem 0; }
|
.cms-richtext { margin: 1rem 0; }
|
||||||
.cms-richtext p { margin-bottom: .75rem; }
|
.cms-richtext p { margin-bottom: .75rem; }
|
||||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||||
.cms-richtext li { margin-bottom: .3rem; }
|
.cms-richtext li { margin-bottom: .35rem; }
|
||||||
.cms-image { margin: 1.5rem 0; }
|
.cms-richtext hr { border: none; border-top: 1px solid var(--color-border); margin: 1.5rem 0; }
|
||||||
.cms-image img { border-radius: 6px; }
|
.cms-image { margin: 1.75rem 0; }
|
||||||
|
.cms-image img { border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||||
.cms-gallery {
|
.cms-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.75rem 0;
|
||||||
}
|
}
|
||||||
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
.cms-gallery-item img { border-radius: 6px; aspect-ratio: 1; object-fit: cover; }
|
||||||
.cms-contact {
|
.cms-contact {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-family: var(--font-ui);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .4rem;
|
gap: .5rem;
|
||||||
margin: 1rem 0;
|
margin: 1.25rem 0;
|
||||||
|
background: var(--color-surface-warm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
.cms-contact-row { display: flex; gap: .6rem; align-items: flex-start; }
|
||||||
|
|
||||||
/* ── Abgabetiere ── */
|
/* ── Abgabetiere — group cards ── */
|
||||||
.cms-abgabe { margin: 1rem 0; }
|
.cms-abgabe { margin: 1.25rem 0; }
|
||||||
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
.cms-abgabe-intro { margin-bottom: 1.5rem; font-size: 1.05rem; }
|
||||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||||
.cms-animal-grid {
|
|
||||||
display: grid;
|
.cms-group-list { display: flex; flex-direction: column; gap: 2rem; }
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 1.25rem;
|
.cms-group-card {
|
||||||
}
|
|
||||||
.cms-animal-card {
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.cms-group-header {
|
||||||
|
padding: 1rem 1.25rem .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem .75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-warm);
|
||||||
|
}
|
||||||
|
.cms-group-name {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.cms-group-meta {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
.cms-animal-photo { width: 100%; aspect-ratio: 4/3; object-fit: cover; }
|
|
||||||
.cms-animal-info { padding: .875rem; }
|
|
||||||
.cms-animal-name { font-size: 1.1rem; font-weight: 600; margin-bottom: .25rem; }
|
|
||||||
.cms-animal-farbe { font-size: .9rem; color: var(--color-muted); margin-bottom: .25rem; }
|
|
||||||
.cms-animal-group { font-size: .85rem; color: var(--color-muted); }
|
|
||||||
.cms-animal-text { font-size: .9rem; margin-top: .5rem; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
/* availability badge */
|
||||||
h1.cms-heading { font-size: 1.4rem; }
|
.cms-badge {
|
||||||
.cms-animal-grid { grid-template-columns: 1fr; }
|
display: inline-block;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
padding: .2rem .55rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cms-badge--free {
|
||||||
|
background: var(--color-badge-free-bg);
|
||||||
|
color: var(--color-badge-free);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* group photo strip */
|
||||||
|
.cms-group-gallery {
|
||||||
|
display: flex;
|
||||||
|
gap: .375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
background: #f5ede4;
|
||||||
|
}
|
||||||
|
.cms-group-gallery::-webkit-scrollbar { display: none; }
|
||||||
|
.cms-group-photo {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* animal detail list */
|
||||||
|
.cms-animal-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: .75rem 1.25rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .875rem;
|
||||||
|
}
|
||||||
|
.cms-animal-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .15rem;
|
||||||
|
padding-bottom: .875rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.cms-animal-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||||
|
.cms-animal-name {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.cms-animal-detail {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
.cms-animal-note {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: .925rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-top: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.site-header { padding: 0 .75rem; min-height: 50px; }
|
||||||
|
.site-logo { font-size: 1rem; padding-right: 1rem; }
|
||||||
|
h1.cms-heading { font-size: 1.5rem; }
|
||||||
|
.site-main { margin-top: 1.5rem; }
|
||||||
|
.cms-group-photo { width: 110px; height: 110px; }
|
||||||
|
}
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
.cms-animal-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||||
|
.cms-animal-row { border-bottom: none; padding-bottom: 0; border: 1px solid var(--color-border); border-radius: 8px; padding: .75rem; }
|
||||||
}
|
}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
{
|
{
|
||||||
g.Id,
|
g.Id,
|
||||||
g.Name,
|
g.Name,
|
||||||
|
g.Gender,
|
||||||
|
g.DateOfBirth,
|
||||||
|
g.CharacterNote,
|
||||||
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
Farbschlag = g.ColorVariety != null ? g.ColorVariety.Name : null,
|
||||||
// group = Becken (enclosure) name — matches how the Abgabe composer
|
// group = Becken (enclosure) name — matches how the Abgabe composer
|
||||||
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
// groups ForSale animals by enclosure-mates (god ruling). null = single.
|
||||||
@@ -109,6 +112,9 @@ namespace GerbilManagerWebAPI.Cms
|
|||||||
arr.Add(new JsonObject
|
arr.Add(new JsonObject
|
||||||
{
|
{
|
||||||
["name"] = a.Name,
|
["name"] = a.Name,
|
||||||
|
["gender"] = a.Gender.ToString(),
|
||||||
|
["dateOfBirth"] = a.DateOfBirth?.ToString("yyyy-MM-dd"),
|
||||||
|
["characterNote"] = a.CharacterNote,
|
||||||
["farbschlag"] = a.Farbschlag,
|
["farbschlag"] = a.Farbschlag,
|
||||||
["group"] = a.Group,
|
["group"] = a.Group,
|
||||||
["photos"] = photoArr,
|
["photos"] = photoArr,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
1348
GerbilManagerWebAPI/Migrations/20260606131032_AddImpressumDatenschutzPages.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddImpressumDatenschutzPages : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Pages",
|
||||||
|
columns: new[] { "Id", "SeoDescription", "Slug", "Status", "Title" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("51720001-0000-0000-0000-000000000007"), null, "impressum", "Published", "Impressum" },
|
||||||
|
{ new Guid("51720001-0000-0000-0000-000000000008"), null, "datenschutz", "Published", "Datenschutz" }
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Blocks",
|
||||||
|
columns: new[] { "Id", "Data", "Order", "PageId", "Type" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000007"), "{\"text\":\"Impressum\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000007"), "Heading" },
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000008"), "{\"text\":\"Datenschutz\",\"level\":1}", 0, new Guid("51720001-0000-0000-0000-000000000008"), "Heading" },
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000070"), "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000007"), "RichText" },
|
||||||
|
{ new Guid("51720002-0000-0000-0000-000000000080"), "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}", 1, new Guid("51720001-0000-0000-0000-000000000008"), "RichText" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000007"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000008"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000070"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Blocks",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720002-0000-0000-0000-000000000080"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Pages",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720001-0000-0000-0000-000000000007"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Pages",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("51720001-0000-0000-0000-000000000008"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1337
GerbilManagerWebAPI/Migrations/20260606134002_ReseedColorVarietiesGen3g.Designer.cs
generated
Normal file
1337
GerbilManagerWebAPI/Migrations/20260606134002_ReseedColorVarietiesGen3g.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReseedColorVarietiesGen3g : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmch DD EE GG PP spsp rere", "CP-Agouti-Hell", 61 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmch DD EE gg PP spsp rere", "CP-Silberagouti-Hell", 62 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmch DD ee GG PP spsp rere", "CP-Algierfuchs-Hell", 63 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmch DD ee gg PP spsp rere", "CP-Polarfuchs-Hell", 64 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmch DD efef GG PP spsp rere", "CP-Orangeschimmel-Hell", 65 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,38 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Order = 1,
|
Order = 1,
|
||||||
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
PageId = new Guid("51720001-0000-0000-0000-000000000003"),
|
||||||
Type = "AbgabetiereList"
|
Type = "AbgabetiereList"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000007"),
|
||||||
|
Data = "{\"text\":\"Impressum\",\"level\":1}",
|
||||||
|
Order = 0,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||||
|
Type = "Heading"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000070"),
|
||||||
|
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||||
|
Order = 1,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||||
|
Type = "RichText"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000008"),
|
||||||
|
Data = "{\"text\":\"Datenschutz\",\"level\":1}",
|
||||||
|
Order = 0,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||||
|
Type = "Heading"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720002-0000-0000-0000-000000000080"),
|
||||||
|
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||||
|
Order = 1,
|
||||||
|
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||||
|
Type = "RichText"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -605,6 +637,41 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel",
|
Name = "CP-Orangeschimmel",
|
||||||
SortOrder = 60
|
SortOrder = 60
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
Name = "CP-Agouti-Hell",
|
||||||
|
SortOrder = 61
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
Name = "CP-Silberagouti-Hell",
|
||||||
|
SortOrder = 62
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
Name = "CP-Algierfuchs-Hell",
|
||||||
|
SortOrder = 63
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
Name = "CP-Polarfuchs-Hell",
|
||||||
|
SortOrder = 64
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
Name = "CP-Orangeschimmel-Hell",
|
||||||
|
SortOrder = 65
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -989,6 +1056,20 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Slug = "kontakt",
|
Slug = "kontakt",
|
||||||
Status = "Published",
|
Status = "Published",
|
||||||
Title = "Kontakt"
|
Title = "Kontakt"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||||
|
Slug = "impressum",
|
||||||
|
Status = "Published",
|
||||||
|
Title = "Impressum"
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||||
|
Slug = "datenschutz",
|
||||||
|
Status = "Published",
|
||||||
|
Title = "Datenschutz"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.SaleAd
|
namespace GerbilManagerWebAPI.SaleAd
|
||||||
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
|
|||||||
public static class SaleAdPromptBuilder
|
public static class SaleAdPromptBuilder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Two few-shot examples derived from the documented listing template.
|
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
|
||||||
/// NOTE: the research file documents the format and one real tagline;
|
/// These are real listings in the breeder's own voice — the strongest grounding source.
|
||||||
/// these examples are synthesized to that template. Swap in verbatim
|
|
||||||
/// listings from kleine-chaoten.jimdofree.com when available.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string ExampleListing1 = """
|
private const string ExampleListing1 = """
|
||||||
Status: FREI
|
Status: LOCKER RESERVIERT LEONIE
|
||||||
|
|
||||||
„Großer und kleiner Bruder Dynamik“ – Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
|
„Großer und kleiner Bruder Dynamik” – Männliches Rennmaus Duo sucht ein liebevolles Zuhause
|
||||||
|
|
||||||
Balu – CP-Agouti, geboren am 12.03.2024
|
Hier steht ein außergewöhnlich harmonisches Rennmaus-Duo zur Abgabe frei, das nicht nur durch seinen Charakter, sondern auch durch seine besondere Geschichte überzeugt. Die zwei verhalten sich als ob sie Brüder wären und sind unzertrennlich. Das Duo kann ab sofort ausziehen.
|
||||||
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
|
|
||||||
|
|
||||||
Benny – Schwarz Schecke, geboren am 12.03.2024
|
Der Ältere von beiden trägt die Farbe CP-Agouti und ist am 11.11.2025 geboren. Er ist ein neugieriger, aktiver und sozialer kleiner Kerl. Er liebt es, seine Umgebung zu erkunden, ist gerne in Bewegung und bringt dabei eine angenehme Ausgeglichenheit mit. Er wirkt sicher, aufmerksam und zeigt ein rundum unkompliziertes Wesen.
|
||||||
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest — getrennt werden die beiden deshalb nicht.
|
|
||||||
|
|
||||||
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
Sein, etwas jünger, Partner trägt die Farbe Schwarz Schecke und ist am 26.02.2026 geboren. Statt des in diesem Alter oft typischen Kräftemessens zeigt er ein erstaunlich sanftes, ruhiges und sehr soziales Verhalten. Man merkt ihm an, wie feinfühlig und verträglich er ist.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string ExampleListing2 = """
|
private const string ExampleListing2 = """
|
||||||
Status: LOCKER RESERVIERT Anna
|
Status: FREI
|
||||||
|
|
||||||
„Zwei Schwestern, ein Herz und ganz viel Neugier“ – Weibliches Duo sucht seine Menschen
|
„Unzertrennliches Mutter-Tochter Duo sucht gemeinsam neue Körnergeber”
|
||||||
|
|
||||||
Frieda – Gold, geboren am 28.06.2024
|
Die Mama ist am 13.08.2025 geboren und trägt die Farbe Kohlfuchs. Sie ist eine eher ruhige und vorsichtige Rennmaus, die neuen Situationen zunächst aufmerksam begegnet. Gibt man ihr etwas Zeit, zeigt sich schnell ihre neugierige Seite und sie kommt gerne schauen, was im Gehege passiert. Sie ist ein echtes Energiebündel, liebt es zu buddeln, zu rennen und alles an Beschäftigungsmaterial – besonders Klorollen – kreativ umzugestalten. Charakterlich zeigt sie sich sehr sozial und friedlich; besonders hervorzuheben ist ihre fürsorgliche Art als Mutter.
|
||||||
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
|
|
||||||
|
|
||||||
Fine – Agouti, geboren am 28.06.2024
|
Die Tochter ist am 26.02.2026 geboren und trägt die Farbe Agouti-Schecke. Sie ist ihrer Mama sehr ähnlich, dabei aber schon ein kleines bisschen mutiger. Auch sie beobachtet erst gerne in Ruhe, bevor die Neugier siegt. Sie ist sehr anhänglich und orientiert sich stark an ihrer Mutter.
|
||||||
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
|
|
||||||
|
|
||||||
Die Schwestern ziehen selbstverständlich nur zusammen um.
|
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
_Stand: 2026-06-06._
|
_Stand: 2026-06-06._
|
||||||
|
|
||||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
||||||
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind
|
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
|
||||||
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
|
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
|
||||||
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|||||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
||||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
||||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
||||||
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) |
|
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
||||||
|
|
||||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
## B. Für Julian — kleine Aktionen (jederzeit)
|
||||||
|
|
||||||
@@ -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.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch 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 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
|
||||||
| 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
|
|
||||||
129
gerbil-manager-web/e2e/abgabe.spec.ts
Normal file
129
gerbil-manager-web/e2e/abgabe.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
|
||||||
|
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
|
||||||
|
*
|
||||||
|
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const ta = de.pages.abgabe
|
||||||
|
const tw = de.pages.vertraege.wizard
|
||||||
|
|
||||||
|
test.describe('Abgabe', () => {
|
||||||
|
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText(ta.grouping.title)).toBeVisible()
|
||||||
|
|
||||||
|
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
// Inserat-Vorschau (listing-preview) wird gerendert
|
||||||
|
await expect(page.locator('.listing-preview').first()).toBeVisible()
|
||||||
|
// Status-Dropdown standard: FREI
|
||||||
|
await expect(
|
||||||
|
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
|
||||||
|
).toHaveValue('free')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: ta.ai.improve }).first().click()
|
||||||
|
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
|
||||||
|
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
mockDb!.saleAdConfigured = false
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: ta.ai.improve }).first().click()
|
||||||
|
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
|
||||||
|
await page.getByRole('button', { name: ta.export.finish }).first().click()
|
||||||
|
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
|
||||||
|
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
|
||||||
|
// Schritt 1: Abnehmer auswählen
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Vertrag-Wizard', () => {
|
||||||
|
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
|
||||||
|
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
|
||||||
|
|
||||||
|
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
|
||||||
|
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
|
||||||
|
await expect(
|
||||||
|
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
|
||||||
|
).toBeChecked()
|
||||||
|
await expect(
|
||||||
|
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
|
||||||
|
).toBeChecked()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 3: Preis & Datum
|
||||||
|
await page.getByLabel(tw.priceLabel).fill('50,00')
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 4: Zusammenfassung + Erzeugen
|
||||||
|
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
|
||||||
|
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
|
||||||
|
await expect(page.getByText('Balu Abgabe')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.generate }).click()
|
||||||
|
|
||||||
|
// Erfolg: Vertrag erstellt, Download-Link vorhanden
|
||||||
|
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu')
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
|
||||||
|
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu')
|
||||||
|
// Schritt 1: Abnehmer wählen
|
||||||
|
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Schritt 2: Tiere bestätigen
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Schritt 3: leeres Preisfeld -> Fehler
|
||||||
|
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
|
|||||||
|
|
||||||
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
||||||
await gotoSection(page, de.nav.contacts)
|
await gotoSection(page, de.nav.contacts)
|
||||||
|
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
|
||||||
|
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
|
||||||
|
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
|
||||||
|
// wartet aber nicht auf den vollständigen Seitenaufbau).
|
||||||
|
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
|
||||||
await page.getByRole('link', { name: tk.newButton }).click()
|
await page.getByRole('link', { name: tk.newButton }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
|
||||||
const name = uniqueName('Kontakt')
|
const name = uniqueName('Kontakt')
|
||||||
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
||||||
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -17,5 +17,10 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
|
|||||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||||
await page.goto('/abgabe')
|
await page.goto('/abgabe')
|
||||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||||
|
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||||
|
const kruemelSection = page.locator('.group-composer__animal').filter({
|
||||||
|
has: page.locator('strong', { hasText: 'Krümel' }),
|
||||||
|
})
|
||||||
|
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
||||||
})
|
})
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,12 +39,23 @@ export function skipUnlessMock() {
|
|||||||
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
|
||||||
|
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
|
||||||
|
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
|
||||||
|
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
|
||||||
|
* wenn wir nur testen, ob die Seite lädt.
|
||||||
|
*/
|
||||||
|
export function skipUnlessLive() {
|
||||||
|
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
||||||
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||||
*/
|
*/
|
||||||
export async function gotoSection(page: Page, label: string) {
|
export async function gotoSection(page: Page, label: string) {
|
||||||
const nav = page.getByRole('navigation')
|
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||||
// App noch nicht geladen (about:blank) -> erst zur Startseite
|
// App noch nicht geladen (about:blank) -> erst zur Startseite
|
||||||
if (!(await nav.isVisible())) {
|
if (!(await nav.isVisible())) {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@@ -63,6 +74,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)}`
|
||||||
|
|||||||
54
gerbil-manager-web/e2e/genotype-table.spec.ts
Normal file
54
gerbil-manager-web/e2e/genotype-table.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-2: Genotyp-Tabellen-Overflow — die Tabelle scrollt horizontal,
|
||||||
|
* die Seite selbst bleibt NICHT breiter als der Viewport (kein horizontaler
|
||||||
|
* Page-Overflow auf 390px).
|
||||||
|
*/
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const tl = de.pages.litters
|
||||||
|
|
||||||
|
test('Phone: Genotyp-Detail-Tabelle scrollt im eigenen Wrapper, kein Page-Overflow', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
// Wurf w-kruemel hat beide Eltern mit Genotypen → BreedingResultView rendert.
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Genotyp-Detail-Tabelle ausklappen.
|
||||||
|
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
|
||||||
|
|
||||||
|
// Scroll-Wrapper und Tabelle müssen vorhanden und sichtbar sein.
|
||||||
|
const wrapper = page.locator('.genotype-table-scroll').first()
|
||||||
|
await expect(wrapper).toBeVisible()
|
||||||
|
await expect(wrapper.locator('.genotype-table')).toBeVisible()
|
||||||
|
|
||||||
|
// Wrapper ist selbst scrollbar (Tabelle breiter als der sichtbare Bereich).
|
||||||
|
const wrapperScrollable = await wrapper.evaluate(
|
||||||
|
(el) => el.scrollWidth > el.clientWidth,
|
||||||
|
)
|
||||||
|
expect(wrapperScrollable).toBe(true)
|
||||||
|
|
||||||
|
// Die Seite selbst darf NICHT breiter als der Viewport sein.
|
||||||
|
const pageOverflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
|
)
|
||||||
|
expect(pageOverflow).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Desktop: Genotyp-Detail-Tabelle zeigt Wrapper auch auf breitem Viewport', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'desktop') return
|
||||||
|
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.genotype-table-scroll').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.genotype-table').first()).toBeVisible()
|
||||||
|
})
|
||||||
302
gerbil-manager-web/e2e/live-data.spec.ts
Normal file
302
gerbil-manager-web/e2e/live-data.spec.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* QA-LIVE-DATA: Tiefe Begehung mit echten Import-Daten (325 Tiere, 865 Würfe, 138 Fotos).
|
||||||
|
* Läuft ausschließlich im LIVE-Modus (E2E_BASE_URL gesetzt).
|
||||||
|
*
|
||||||
|
* Bekannte Datengrundlage (Stand Re-Import #2.5):
|
||||||
|
* - Vance von den Kleinen Chaoten id=afdaff89-0274-4778-8f2f-3957a81bf58e (Resident, Stammbaum)
|
||||||
|
* - Girasol of Topolino id=bf20f105-2ea9-475f-89b7-d50e59fb7d3b (9 Fotos)
|
||||||
|
* - Litter 2026-02-10 id=a1db02cf-48ff-4f3a-8eab-33f2360c27f0 (beide Eltern gesetzt)
|
||||||
|
* - 204 Residents, 121 Externe, 15 namenlose Einträge
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessLive, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
const tl = de.pages.litters
|
||||||
|
const td = t.detail
|
||||||
|
|
||||||
|
// ── 1. RENNMÄUSE-LISTE ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Tiere-Liste lädt 325 Einträge (Bestand-Default-Filter inklusive externe)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
const count = await rows.count()
|
||||||
|
// Alle 325 oder zumindest Paginierung der ersten Seite (>=20)
|
||||||
|
expect(count).toBeGreaterThanOrEqual(20)
|
||||||
|
// Count-Label sichtbar ("204 Tiere" für Bestand-Default-Filter oder "325 Tiere")
|
||||||
|
await expect(page.locator('p.muted').filter({ hasText: t.countLabel })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Bestand-Filter sichtbar + umschaltbar (Resident vs. Alle)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
await openFilterPanel(page)
|
||||||
|
// Filter-Panel oder mindestens der "Nur Bestand"-Toggle ist sichtbar
|
||||||
|
const filterSection = page.locator('.filter-panel, [aria-label*="Filter"], .filter-panel__content')
|
||||||
|
await expect(filterSection.first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Suche nach "Vance" findet mindestens einen Treffer', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Vance')
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(1)
|
||||||
|
// Name "Vance" soll in einem Ergebnis auftauchen
|
||||||
|
await expect(page.locator('.gerbil-row').filter({ hasText: 'Vance' }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Suche nach "von den" (Namensmuster) liefert Treffer', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
await page.getByPlaceholder(t.searchPlaceholder).first().fill('von den')
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Genotyp-Tabelle scrollt horizontal (kein Page-Overflow bei 390px)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.locator('.gerbil-row').first()).toBeVisible()
|
||||||
|
// Kein horizontaler Scroll auf body/html
|
||||||
|
const bodyOverflow = await page.evaluate(() => {
|
||||||
|
const body = document.body
|
||||||
|
const html = document.documentElement
|
||||||
|
return {
|
||||||
|
bodyScrollWidth: body.scrollWidth,
|
||||||
|
htmlScrollWidth: html.scrollWidth,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(bodyOverflow.bodyScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
|
||||||
|
expect(bodyOverflow.htmlScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. TIER-DETAIL ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Girasol of Topolino Detail zeigt Fotos-Tab mit mindestens 9 Fotos', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/rennmaeuse/bf20f105-2ea9-475f-89b7-d50e59fb7d3b')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Girasol of Topolino' })).toBeVisible()
|
||||||
|
// Fotos-Tab anklicken
|
||||||
|
await page.getByRole('tab', { name: td.tabs.photos }).click()
|
||||||
|
// Mindestens ein Foto-Img sichtbar (9 Fotos importiert)
|
||||||
|
const photos = page.locator('img.photo-thumb, img[src*="/photos/files/"], .photo-item img, .photo-list img')
|
||||||
|
await expect(photos.first()).toBeVisible({ timeout: 8_000 })
|
||||||
|
expect(await photos.count()).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Vance von den Kleinen Chaoten Detail rendert korrekt', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
|
||||||
|
// Gesundheits-Tab (leer OK, soll aber nicht crashen)
|
||||||
|
await page.getByRole('tab', { name: td.tabs.health }).click()
|
||||||
|
await expect(page).not.toHaveURL('/error')
|
||||||
|
// Gewicht-Tab
|
||||||
|
await page.getByRole('tab', { name: td.tabs.weight }).click()
|
||||||
|
await expect(page).not.toHaveURL('/error')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Stammbaum-Link von Tier-Detail navigiert zu StammbaumPage', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
|
||||||
|
// Stammbaum-Link suchen
|
||||||
|
const stammbaumLink = page.getByRole('link', { name: /Stammbaum/i }).first()
|
||||||
|
if (await stammbaumLink.isVisible()) {
|
||||||
|
await stammbaumLink.click()
|
||||||
|
await expect(page).toHaveURL(/stammbaum/)
|
||||||
|
} else {
|
||||||
|
// Direkt navigieren
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||||
|
}
|
||||||
|
// Stammbaum-SVG oder rd3t-Knoten sichtbar
|
||||||
|
await expect(page.locator('[data-testid="rd3t-svg"], svg.rd3t-svg, .stammbaum-page, svg').first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. STAMMBAUM-SEITE ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Stammbaum rendert ohne Page-Overflow (390px)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||||
|
// Irgendein SVG-Element muss sichtbar sein
|
||||||
|
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
bodyScrollWidth: document.body.scrollWidth,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
}))
|
||||||
|
expect(overflow.bodyScrollWidth).toBeLessThanOrEqual(overflow.viewportWidth + 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Stammbaum hat Druck-Button', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||||
|
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||||
|
const printBtn = page.locator('button, a').filter({ hasText: /Drucken|Print|Ahnentafel/i })
|
||||||
|
await expect(printBtn.first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. WÜRFE-LISTE ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Würfe-Liste zeigt mindestens 20 Einträge (865 Würfe importiert)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.litters)
|
||||||
|
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
|
||||||
|
const rows = page.locator('.litter-row, .gerbil-card, li a').filter({ hasText: /Wurf/i })
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Wurf-Detail mit beiden Eltern rendert "Elterntiere"', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Direkt zu einem Wurf mit beiden Eltern navigieren (2026-02-10)
|
||||||
|
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
|
||||||
|
// Elterntiere-Section immer sichtbar
|
||||||
|
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Vater-Link zu einem Tier
|
||||||
|
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Wurf-Detail ohne Eltern rendert "Elterntiere" als "unbekannt"', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Wurf A (2010-02-18) hat weder Vater noch Mutter
|
||||||
|
await page.goto('/wuerfe/34852596-2bd7-48ad-9175-4223d3d99229')
|
||||||
|
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// "unbekannt" Platzhalter soll sichtbar sein (kein Link)
|
||||||
|
await expect(page.getByText(tl.detail.unknownParent ?? 'unbekannt')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5. BECKEN / KONTAKTE (leere Zustände) ────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Becken-Seite zeigt Leer-Zustand (keine Becken importiert)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.enclosures)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Leer-Meldung oder "Neues Becken"-Button
|
||||||
|
const emptyOrNew = page.locator('*').filter({ hasText: de.pages.becken.empty }).or(
|
||||||
|
page.getByRole('link', { name: de.pages.becken.newButton })
|
||||||
|
)
|
||||||
|
await expect(emptyOrNew.first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live-Data: Kontakte-Seite zeigt Leer-Zustand (keine Kontakte importiert)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.contacts)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.kontakte.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.getByText(de.pages.kontakte.empty)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 6. GENETIK / PROBEVERPAARUNG ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Genetik-Seite lädt mit echten Genotypen', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.genetics)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.genetik.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Probeverpaarung-UI sichtbar
|
||||||
|
await expect(page.getByText(de.pages.genetik.subtitle)).toBeVisible()
|
||||||
|
// Tier-Auswahl Vater/Mutter sichtbar
|
||||||
|
await expect(page.locator('select, input[type="search"], [placeholder]').first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 7. STATISTIK ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Statistik-Seite rendert Charts ohne Crash', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.statistics)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.statistik.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Mindestens ein Chart-Canvas oder SVG (865 Würfe = Charts vorhanden)
|
||||||
|
const chart = page.locator('canvas, svg.bar-chart, svg.line-chart, .chart-wrapper, .bar-chart, .line-chart')
|
||||||
|
await expect(chart.first()).toBeVisible({ timeout: 12_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 8. EINSTELLUNGEN ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Einstellungen-Seite lädt Züchterprofil', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.settings)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.getByText(de.pages.einstellungen.zuchtprofil.title)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 9. WEBSEITE (CMS) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Webseite-Seite lädt ohne Crash', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.website)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.webseite.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Seitenliste mit Seeds vorhanden (8 Seiten aus dem Seed)
|
||||||
|
await expect(page.locator('.gerbil-card, li').first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 10. ANFRAGEN ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Anfragen-Seite zeigt Leer-Zustand (0 Anfragen)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.requests)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.anfragen.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Leer-Text soll sichtbar sein (keine Anfragen live)
|
||||||
|
await expect(page.getByText(de.pages.anfragen.empty)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 11. KONSOLEN-FEHLER-MONITOR ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('Live-Data: Keine unhandled-rejection-Fehler beim Durchlauf aller Hauptseiten', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
const errors: string[] = []
|
||||||
|
page.on('pageerror', (err) => errors.push(err.message))
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigiere direkt per URL statt gotoSection — gotoSection benutzt getByRole('navigation')
|
||||||
|
// welches bei Seiten mit Pager-Navigation (>1 Page) in strict-mode fehlschlägt (Infra-Bug).
|
||||||
|
for (const path of ['/', '/rennmaeuse', '/statistik', '/genetik', '/einstellungen']) {
|
||||||
|
await page.goto(path)
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
}
|
||||||
|
// Keine unkritischen Errors (ignoriere CORS-Warnungen, die im Dev-Modus normal sind)
|
||||||
|
const criticalErrors = errors.filter(
|
||||||
|
(e) =>
|
||||||
|
!e.includes('CORS') &&
|
||||||
|
!e.includes('favicon') &&
|
||||||
|
!e.includes('DevTools') &&
|
||||||
|
!e.includes('Warning:'),
|
||||||
|
)
|
||||||
|
expect(criticalErrors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 12. DESKTOP-OVERFLOW-CHECK ───────────────────────────────────────────────
|
||||||
|
// (dieses Projekt-File läuft in beiden Viewports dank playwright.config.ts)
|
||||||
|
|
||||||
|
test('Live-Data: Rennmäuse-Liste kein horizontal Overflow (Desktop 1280px)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.locator('.gerbil-row').first()).toBeVisible()
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 2)
|
||||||
|
})
|
||||||
52
gerbil-manager-web/e2e/live.spec.ts
Normal file
52
gerbil-manager-web/e2e/live.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* QA-2: Live-Modus-Smoke — läuft NUR gegen das echte Backend (E2E_BASE_URL).
|
||||||
|
*
|
||||||
|
* Lektion (aus dem Such-Bug): Mock-Tests spiegeln nur den Frontend-Code.
|
||||||
|
* Ein Backend-Bug (falscher Filter, leere Antwort, fehlende Migration) bleibt
|
||||||
|
* unsichtbar, wenn Live-Tests nur "Seite lädt" prüfen. Diese Tests assertieren
|
||||||
|
* ERGEBNISSE: Liste nicht leer, Suche liefert Treffer, Detail zeigt Stammdaten
|
||||||
|
* und Eltern-Links.
|
||||||
|
*
|
||||||
|
* Voraussetzung für den Eltern-Link-Test: Re-Import #2 durchgeführt
|
||||||
|
* (322 Tiere, 882 Würfe live; mindestens ein Wurf hat beide Eltern).
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessLive, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
const tl = de.pages.litters
|
||||||
|
|
||||||
|
test('Live: Tiere-Liste enthält nach Re-Import mindestens 10 Einträge', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
// Leere Liste = Backend-Bug (falscher Filter, fehlende DB-Migration o. ä.)
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live: Namenssuche gibt Treffer zurück — nicht nur HTTP 200', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
// Kurzer Prefix: bei 322 Tieren mindestens 1 Treffer. Kein Treffer = Gridify-Bug.
|
||||||
|
await page.getByPlaceholder(t.searchPlaceholder).first().fill('a')
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Direkt zu einem Wurf mit beiden Eltern navigieren (Re-Import #2.5, stabil).
|
||||||
|
// Wurf (aus Diagramm) 2026-02-10 — fatherId + motherId beide gesetzt.
|
||||||
|
// Kein nth()-Selektor mehr: bei 865 Würfen + Pager-Nav nicht deterministisch.
|
||||||
|
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
|
||||||
|
|
||||||
|
// Detailseite: Elterntier-Abschnitt immer sichtbar (kein bedingtes Rendering)
|
||||||
|
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
|
||||||
|
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
})
|
||||||
@@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
||||||
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
||||||
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
||||||
|
contracts: collection(db.contracts as unknown as Row[], 'contract'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (route: Route) => {
|
const handler = async (route: Route) => {
|
||||||
@@ -135,6 +136,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' })
|
||||||
@@ -182,6 +206,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route
|
||||||
|
if (path === '/gerbils/sale-ad' && method === 'POST') {
|
||||||
|
if (!db.saleAdConfigured) {
|
||||||
|
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||||
|
}
|
||||||
|
return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" – Generierter Inserat-Text für den Mock.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient)
|
||||||
|
const cm = path.match(/^\/contracts\/([^/]+)\/file$/)
|
||||||
|
if (cm && method === 'GET') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` },
|
||||||
|
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||||
// generischen /gerbils/:id-Route abfangen.
|
// generischen /gerbils/:id-Route abfangen.
|
||||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||||
@@ -290,6 +333,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben
|
||||||
|
if (path === '/contracts' && method === 'POST') {
|
||||||
|
const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null }
|
||||||
|
const id = newId('contract')
|
||||||
|
const contract = {
|
||||||
|
id,
|
||||||
|
contactId: body.contactId,
|
||||||
|
gerbilIds: body.gerbilIds ?? [],
|
||||||
|
price: body.price,
|
||||||
|
handoverDate: body.handoverDate,
|
||||||
|
contractDate: body.contractDate ?? body.handoverDate,
|
||||||
|
fileName: `vertrag-${id}.docx`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
url: `/contracts/${id}/file`,
|
||||||
|
}
|
||||||
|
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
|
||||||
|
// Tiere auf GivenAway setzen (wie das echte Backend)
|
||||||
|
for (const gid of body.gerbilIds ?? []) {
|
||||||
|
const g = db.gerbils.find((x) => x.id === gid)
|
||||||
|
if (g) {
|
||||||
|
g.status = 'GivenAway'
|
||||||
|
g.receiverContactId = body.contactId
|
||||||
|
g.goHomeDate = body.handoverDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(route, 201, contract)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ export interface MockPage {
|
|||||||
blocks: MockBlock[]
|
blocks: MockBlock[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ABGABE: Abgabevertrag (mirrors SaleContract in src/api/contracts.ts). */
|
||||||
|
export interface MockContract {
|
||||||
|
id: string
|
||||||
|
contactId: string
|
||||||
|
price: number
|
||||||
|
handoverDate: string
|
||||||
|
contractDate: string
|
||||||
|
fileName: string
|
||||||
|
createdAt: string
|
||||||
|
gerbilIds: string[]
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
gerbils: Gerbil[]
|
gerbils: Gerbil[]
|
||||||
litters: Litter[]
|
litters: Litter[]
|
||||||
@@ -58,6 +71,9 @@ export interface MockDb {
|
|||||||
requests: InboxRequest[]
|
requests: InboxRequest[]
|
||||||
aiConfigured: boolean
|
aiConfigured: boolean
|
||||||
mailConfigured: boolean
|
mailConfigured: boolean
|
||||||
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
|
contracts: MockContract[]
|
||||||
|
saleAdConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -119,6 +135,20 @@ export function seedDb(): MockDb {
|
|||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||||
|
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||||
|
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
||||||
|
{
|
||||||
|
...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'),
|
||||||
|
status: 'ForSale' as const,
|
||||||
|
enclosureId: 'enc-leer',
|
||||||
|
notes: 'ruhig, nimmt Leckerlis aus der Hand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...gerbil('sale-benny', 'Benny Abgabe', 'male', '2024-03-12', null, 'cv-schwarz-schecke'),
|
||||||
|
status: 'ForSale' as const,
|
||||||
|
enclosureId: 'enc-leer',
|
||||||
|
notes: 'neugieriger Entdecker',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const litters: Litter[] = [
|
const litters: Litter[] = [
|
||||||
@@ -258,5 +288,7 @@ export function seedDb(): MockDb {
|
|||||||
requests,
|
requests,
|
||||||
aiConfigured: true,
|
aiConfigured: true,
|
||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
|
contracts: [],
|
||||||
|
saleAdConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")`) })
|
||||||
@@ -96,6 +98,35 @@ test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', as
|
|||||||
await expect(page.getByText(/85\s*g/)).toBeVisible()
|
await expect(page.getByText(/85\s*g/)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
// Krümel hat litterId 'w-kruemel' → Name 'Wurf K' → Link /wuerfe/w-kruemel
|
||||||
|
const litterLink = page.getByRole('link', { name: 'Wurf K' })
|
||||||
|
await expect(litterLink).toBeVisible()
|
||||||
|
await litterLink.click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
|
||||||
|
await page.goto('/rennmaeuse/fridolin')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
|
||||||
|
// Kein Link mit diesem Namen — es ist reiner Text
|
||||||
|
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF-LINK-Addendum)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel hat originContactId='con-meier' → Link /kontakte/con-meier (Priorität vor originBreeder)
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
const originLink = page.getByRole('link', { name: 'Zoohandlung Meier' })
|
||||||
|
await expect(originLink).toBeVisible()
|
||||||
|
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
||||||
|
})
|
||||||
|
|
||||||
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
|||||||
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}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showGenotypes && (
|
{showGenotypes && (
|
||||||
|
<div className="genotype-table-scroll">
|
||||||
<table className="genotype-table">
|
<table className="genotype-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -87,6 +88,7 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,10 +179,11 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
|
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
|
||||||
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
|
||||||
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
|
||||||
expect(CATALOG_SIZE).toBe(61)
|
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
|
||||||
|
expect(CATALOG_SIZE).toBe(66)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||||
@@ -414,8 +415,12 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
|||||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('A- + cchm/ch -> CP-<base colour>', () => {
|
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
|
||||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||||
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
@@ -471,10 +476,50 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
|||||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
|
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
|
||||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||||
|
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||||
|
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||||
|
|
||||||
|
it('all new -Hell het entries exist in catalog', () => {
|
||||||
|
for (const n of [
|
||||||
|
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
|
||||||
|
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
|
||||||
|
]) {
|
||||||
|
expect(has(n)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
|
||||||
|
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||||
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
|
||||||
|
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||||
|
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||||
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
|
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||||
|
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||||||
|
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||||
|
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,27 +107,29 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||||
|
|
||||||
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||||
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||||
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
|
||||||
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
|
||||||
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
|
||||||
// in her live data (god: extract animals.json) so the re-import name-matches and
|
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
|
||||||
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
|
||||||
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
|
|
||||||
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
|
|
||||||
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
|
|
||||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||||
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||||
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||||
|
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||||
|
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||||
@@ -207,12 +209,14 @@ function baseColourFor(g: Genotype): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||||
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
||||||
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
||||||
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
||||||
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
||||||
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
|
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
|
||||||
|
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
|
||||||
|
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
|
||||||
*/
|
*/
|
||||||
function colourpointName(g: Genotype): string | null {
|
function colourpointName(g: Genotype): string | null {
|
||||||
const c = resolvedPair(g, 'C')
|
const c = resolvedPair(g, 'C')
|
||||||
@@ -227,9 +231,9 @@ function colourpointName(g: Genotype): string | null {
|
|||||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||||
return bothCchm ? 'Marder' : 'Siam'
|
return bothCchm ? 'Marder' : 'Siam'
|
||||||
}
|
}
|
||||||
// A- colourpoint -> CP-<base colour>, base as if C were full.
|
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
return base ? `CP-${base}` : null
|
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||||
|
|||||||
@@ -340,42 +340,67 @@
|
|||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 54
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
"sortOrder": 54,
|
"sortOrder": 55,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 56
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
"sortOrder": 55,
|
"sortOrder": 57,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 58
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
"sortOrder": 56,
|
"sortOrder": 59,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
"sortOrder": 57
|
"sortOrder": 61
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"name": "CP-Fuchs-Hell",
|
||||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||||
"sortOrder": 58
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"name": "CP-Blaufuchs",
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
"sortOrder": 59
|
"sortOrder": 63
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"name": "CP-Orangeschimmel",
|
||||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
"sortOrder": 60
|
"sortOrder": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 65
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ a {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
max-width: 60rem;
|
max-width: 60rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
|
||||||
|
the page body scroll horizontally. The .genotype-table-scroll wrapper
|
||||||
|
provides the per-table horizontal scroll. */
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab-Leiste unten */
|
/* Tab-Leiste unten */
|
||||||
@@ -559,8 +563,14 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
|
||||||
|
instead of overflowing the page. */
|
||||||
|
.genotype-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
.genotype-table {
|
.genotype-table {
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -571,6 +581,7 @@ textarea {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genotype-table code {
|
.genotype-table code {
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -173,8 +173,22 @@ export default function GerbilDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
||||||
<Row label={t.fields.litter} value={lookup(litterName, g.litterId)} />
|
<Row
|
||||||
<Row label={t.fields.origin} value={lookup(contactName, g.originContactId)} />
|
label={t.fields.litter}
|
||||||
|
value={
|
||||||
|
g.litterId && litterName.has(g.litterId)
|
||||||
|
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||||
|
: lookup(litterName, g.litterId)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t.fields.origin}
|
||||||
|
value={
|
||||||
|
g.originContactId && contactName.has(g.originContactId)
|
||||||
|
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
||||||
|
: (g.originBreeder || null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||||
<Row label={t.fields.notes} value={g.notes} />
|
<Row label={t.fields.notes} value={g.notes} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -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,7 +127,8 @@ export default function GerbilsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
searchField={
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
className="input"
|
className="input"
|
||||||
@@ -127,6 +137,10 @@ export default function GerbilsPage() {
|
|||||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||||
aria-label={t.fields.name}
|
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