Compare commits
88 Commits
c93c24f37c
...
feature/ux
| Author | SHA1 | Date | |
|---|---|---|---|
| d116b8d8f1 | |||
| a2f5e848d1 | |||
| de293904bb | |||
| 68447896ee | |||
| 5f26594a12 | |||
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| c357a18418 | |||
| 791e9f91cc | |||
| efce79b3fa | |||
| 868c5f5cd7 | |||
| ddfa3614a4 | |||
| 13da8f2502 | |||
| 13eb17b453 | |||
| 9ed68ba38a | |||
| dfcd296119 | |||
| db85a6e0dc | |||
| 0c94cfcbf1 | |||
| 5eadd89bb6 | |||
| f9a68deb7a | |||
| a693095886 | |||
| d5544412bd | |||
| d93e8d1586 | |||
| 114bbd92c8 | |||
| a8d8ae0dfc | |||
| 522f2eec51 | |||
| 3f71d8e28e | |||
| 0e7ec5ab61 | |||
| 12604fba7c | |||
| 311c5461fd | |||
| 3634d6ef9a | |||
| aa345aa796 | |||
| 0317c5d454 | |||
| ea8c3e9966 | |||
| 4d3383a4f1 | |||
| 19a0a6c79b | |||
| b9033238c9 | |||
| 594923e10f | |||
| aa20d6f809 | |||
| afd321ec9f | |||
| ae825ae80a | |||
| e055061d85 | |||
| df13136955 | |||
| 4aca1d528b | |||
| 4e02133d95 | |||
| f3efd4aa2b | |||
| 461d7feb2d | |||
| 7536aba474 | |||
| 972570b142 | |||
| 029ffb9895 | |||
| 6decd59ce4 | |||
| f015274d28 | |||
| fd48f7fc09 | |||
| 105e63b9c4 | |||
| 0334fce275 | |||
| aaaf5b6fb7 | |||
| c20e6b5426 | |||
| 36454f3747 | |||
| 3335502ef1 | |||
| 0766f8303c | |||
| 6357e77060 | |||
| 5edbd67c96 | |||
| 0651e54343 | |||
| 4dcd416ff6 | |||
| 5fd8cf4395 | |||
| 76fcad6168 | |||
| 91fa9058de | |||
| 6775707387 | |||
| 8125c453d0 | |||
| 8101cc7be5 | |||
| ea79703dbf | |||
| ed576c0339 | |||
| c35a6eca04 | |||
| 2e20df624f | |||
| a57b481a7e | |||
| 27424c574f | |||
| fd28b2a954 | |||
| 8aab9d34f7 | |||
| 6dabb5572c | |||
| 36db013b76 | |||
| 60d034cde4 | |||
| 5ec04984c9 | |||
| e2fd32ea12 | |||
| 2f089a902d | |||
| 7aac59f3e5 | |||
| a0b720df73 | |||
| 1e7dc8f91d |
@@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly SqliteConnection _connection = new("DataSource=:memory:");
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
|
||||
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
|
||||
/// EINEN öffentlichen (parameterlosen) Konstruktor.
|
||||
/// </summary>
|
||||
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
|
||||
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
@@ -46,6 +53,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
|
||||
|
||||
ConfigureTestServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(1, root.GetProperty("gerbils").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("litters").GetArrayLength());
|
||||
Assert.Equal(1, root.GetProperty("contacts").GetArrayLength());
|
||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 70); // HasData-Seed
|
||||
Assert.True(root.GetProperty("colorVarieties").GetArrayLength() >= 60); // HasData-Seed (61 nach GEN-3f)
|
||||
var g = root.GetProperty("gerbils")[0];
|
||||
Assert.Equal("Aa CC Dd EE GG Pp Spsp rere", g.GetProperty("genotype").GetString());
|
||||
Assert.Equal("Stammbaum von Akio Kids.xlsx", g.GetProperty("importSource").GetString());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
@@ -102,6 +103,208 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
var a1 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "a1");
|
||||
// GEN-3b: deafness is a persisted phenotype flag (NOT a genotype locus).
|
||||
Assert.True(a1.IsDeaf);
|
||||
// Sls (2nd spotting locus) + provenance tags are preserved in RawImportData
|
||||
// (kept out of the 8-locus compact Genotype contract until GEN-3a adopts them).
|
||||
Assert.Contains("Sls", a1.RawImportData!);
|
||||
Assert.Contains("WFNZ", a1.RawImportData!);
|
||||
// GEN-3a contract: a WP/Sls carrier appends the trailing "Slsl" token (Kevin).
|
||||
Assert.EndsWith("Slsl", a1.Genotype!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_appends_Slsl_only_for_carriers()
|
||||
{
|
||||
// wild-type sl/sl is omitted -> plain 8-locus string
|
||||
var wild = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "sl", "sl" } } };
|
||||
Assert.DoesNotContain("Sl", ImportService.ComposeGenotype(wild));
|
||||
// WP heterozygote -> trailing Slsl
|
||||
var wp = new SourceGenotype { Mapped8locus = new() { ["A"] = new() { "a", "a" }, ["Sls"] = new() { "Sl", "sl" } } };
|
||||
Assert.EndsWith("Slsl", ImportService.ComposeGenotype(wp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Synthesizes_litter_from_chart_parentRefs_links_offspring_and_parents()
|
||||
{
|
||||
// Offspring 'C' has chart-position parentRefs to father 'Papa' (loaded) and mother
|
||||
// 'Mama' (loaded), but NO Wurfchronik litterRef -> the loader must synthesize a derived
|
||||
// litter, link C to it, and set the litter's Father/Mother (PEDIGREE-LINK structural fix).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "pedlink-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ParentLinksFromChart);
|
||||
Assert.Equal(1, report.Litters.DerivedFromChart);
|
||||
|
||||
var papa = await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa");
|
||||
var mama = await db.Gerbils.SingleAsync(g => g.ExternalRef == "mama");
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId); // C no longer "unbekannt"
|
||||
|
||||
// box-colour sex flows through (blue=male, white=female)
|
||||
Assert.Equal(Gender.male, papa.Gender);
|
||||
Assert.Equal(Gender.female, mama.Gender);
|
||||
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal(papa.Id, litter.FatherId);
|
||||
Assert.Equal(mama.Id, litter.MotherId);
|
||||
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
|
||||
|
||||
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
|
||||
// resident (rule b); the unrelated foreign animal stays external.
|
||||
Assert.True(c.IsResident); // rule (a)
|
||||
Assert.True(papa.IsResident); // rule (b) parent exception
|
||||
Assert.True(mama.IsResident); // rule (b)
|
||||
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
|
||||
Assert.NotNull(report.Residency);
|
||||
Assert.Equal(3, report.Residency!.Resident);
|
||||
Assert.Equal(1, report.Residency.External);
|
||||
Assert.Equal(2, report.Residency.FlippedByParentRule);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_on_relational_db_with_new_chart_parents_does_not_FK_throw()
|
||||
{
|
||||
// Regression for FK_Litters_Gerbils_FatherId: a derived litter references parent gerbils
|
||||
// created in the SAME run, so they must be inserted before the litter. The EF in-memory
|
||||
// provider does NOT enforce FKs (which masked the bug), so this uses SQLite — which does.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkfix-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"Mama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync(); // schema WITH enforced FK constraints
|
||||
|
||||
// must not throw FK_Litters_Gerbils_FatherId (parents inserted before the litter)
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var c = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c");
|
||||
Assert.NotNull(c.LitterId);
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c.LitterId);
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "papa")).Id, litter.FatherId);
|
||||
Assert.Equal(0, report.Litters.ParentFksDropped);
|
||||
Assert.Equal(0, report.Litters.DerivedSkipped);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||
{
|
||||
// A chart parentRef pointing to a QUARANTINED (conflict) animal must not become an FK —
|
||||
// the derived litter keeps that side null; if both sides are unresolvable, no litter.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "fkq-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"badpa","name":"BadPapa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"goodma","name":"GoodMama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"c2","name":"C2","dob":"29.04.2024","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"parentRefs":[
|
||||
{"name":"BadPapa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
|
||||
{"name":"GoodMama v.d. Test","dob":"02.02.2022","roleGuess":"mother","method":"chart-position","confidence":"medium"}
|
||||
]}
|
||||
]
|
||||
""");
|
||||
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); // must not throw
|
||||
|
||||
Assert.False(await db.Gerbils.AnyAsync(g => g.ExternalRef == "badpa")); // quarantined
|
||||
var c2 = await db.Gerbils.SingleAsync(g => g.ExternalRef == "c2");
|
||||
Assert.NotNull(c2.LitterId); // still linked (via mother)
|
||||
var litter = await db.Litters.SingleAsync(l => l.Id == c2.LitterId);
|
||||
Assert.Null(litter.FatherId); // quarantined father -> null FK
|
||||
Assert.Equal((await db.Gerbils.SingleAsync(g => g.ExternalRef == "goodma")).Id, litter.MotherId);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Decision_resolved_animal_loads_and_is_counted()
|
||||
{
|
||||
// extract.py clears the conflict + sets resolvedByDecision when a human conflict-decision
|
||||
// un-quarantines an animal; the loader must then LOAD it and surface the count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"firefly","name":"Firefly","dob":"18.12.2019","death":"","farbschlag":"Agouti",
|
||||
"genotype":{"mapped8locus":{"A":["A","a"],"D":["D","?"]},"rawGenotype":"Aa D-","unmappedTokens":[]},
|
||||
"conflict":false,"resolvedByDecision":true}
|
||||
]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
Assert.Equal(1, report.Animals.ConflictsResolvedByDecision);
|
||||
Assert.True(await db.Gerbils.AnyAsync(g => g.ExternalRef == "firefly")); // loaded, not quarantined
|
||||
Assert.Equal(0, report.Animals.Quarantined.Conflicts);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComposeGenotype_strips_carets_and_fills_missing_loci()
|
||||
{
|
||||
@@ -118,6 +321,230 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal("aa Ccchm ?? eef ?? ?? ?? ??", ImportService.ComposeGenotype(g));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_fills_null_litter_parent_on_reimport()
|
||||
{
|
||||
// Run 1: litter "Wurf A" has sire "Vater" (conflict=true — not loaded) and dam "Mutter"
|
||||
// (conflict=false — loaded). After run 1: litter.FatherId = null.
|
||||
// Run 2: sire "Vater" now conflict=false → loaded as NEW in run 2. Backfill via
|
||||
// createdAnimalByName sets FatherId. (god steering point 3: run-2 path.)
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-A","litterId":"A","date":"01.05.2023","damName":"Mutter [ZdkC]","sireName":"Vater [ZdkC]","totalBorn":3,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: Vater is in conflict -> not loaded
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var report1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, report1.Litters.ParentFksBackfilled);
|
||||
var litter1 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Null(litter1.FatherId); // Vater was quarantined -> null FK
|
||||
Assert.NotNull(litter1.MotherId); // Mutter was loaded -> set
|
||||
|
||||
// Run 2: Vater now conflict=false -> loaded as NEW animal in this run
|
||||
var animals2 = """
|
||||
[
|
||||
{"id":"mutter","name":"Mutter [ZdkC]","dob":"01.01.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"vater","name":"Vater [ZdkC]","dob":"02.02.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"kind","name":"Kind [ZdkC]","dob":"01.05.2023","death":"","farbschlag":"","gender":null,"zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||
"litterRef":{"litterId":"L-A","method":"geburtsdatum+eltern","confidence":"hoch"}}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report2.Litters.ParentFksBackfilled); // backfill happened
|
||||
var vater = await db.Gerbils.SingleAsync(g => g.ExternalRef == "vater");
|
||||
var litter2 = await db.Litters.SingleAsync(l => l.Name == "Wurf A");
|
||||
Assert.Equal(vater.Id, litter2.FatherId); // FK now set
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_uses_allDb_lookup_when_parent_not_in_current_loadable()
|
||||
{
|
||||
// god steering point 3: the main case — parent was loaded in a PREVIOUS run (not in
|
||||
// the current run's animals.json at all). Backfill must find them via allDbNormToGid.
|
||||
//
|
||||
// Run 1: litter "Wurf C" + dam loaded, sire quarantined -> FatherId null.
|
||||
// Run 2: sire loaded (new animal).
|
||||
// Run 3: animals.json has ONLY the kind (sire absent from extract). Sire is in DB
|
||||
// from run 2 but NOT in the current run's loadable/createdAnimalByName.
|
||||
// Backfill must use allDbNormToGid to find him.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-db-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-C","litterId":"C","date":"10.06.2023","damName":"Dame [ZdkC]","sireName":"Herr [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
// Run 1: sire quarantined
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""");
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Null((await db.Litters.SingleAsync(l => l.Name == "Wurf C")).FatherId);
|
||||
|
||||
// Run 2: sire now loaded
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"herr","name":"Herr [ZdkC]","dob":"06.06.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
var herrId = (await db.Gerbils.SingleAsync(g => g.ExternalRef == "herr")).Id;
|
||||
// Run 2 itself may or may not backfill (depends on name normalization alignment).
|
||||
// For the test we care about run 3.
|
||||
|
||||
// Run 3: sire NOT in animals.json at all (absent from new extract).
|
||||
// litter still has FatherId=null if run 2 didn't backfill; if it did, we simulate
|
||||
// by manually resetting FatherId to null so run 3 must fix it.
|
||||
var litter3 = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
litter3.FatherId = null;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[
|
||||
{"id":"dame","name":"Dame [ZdkC]","dob":"05.05.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false}
|
||||
]
|
||||
""");
|
||||
// Run 3: sire absent from loadable (NOT in createdAnimalByName), but IS in DB.
|
||||
var report3 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, report3.Litters.ParentFksBackfilled); // allDbNormToGid path
|
||||
var litter3After = await db.Litters.SingleAsync(l => l.Name == "Wurf C");
|
||||
Assert.Equal(herrId, litter3After.FatherId); // FK set from DB lookup
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParentFkBackfill_dry_run_counts_without_writing()
|
||||
{
|
||||
// Dry-run on a DB with an existing null-parent litter should predict the backfill count.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "backfill-dr-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
try
|
||||
{
|
||||
var littersJson = """
|
||||
[{"id":"L-B","litterId":"B","date":"15.06.2023","damName":"Mami [ZdkC]","sireName":"Papi [ZdkC]","totalBorn":2,"zuchtnummer":"","note":""}]
|
||||
""";
|
||||
var animals1 = """
|
||||
[
|
||||
{"id":"mami","name":"Mami [ZdkC]","dob":"03.03.2021","death":"","farbschlag":"","gender":"female","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
|
||||
{"id":"papi","name":"Papi [ZdkC]","dob":"04.04.2021","death":"","farbschlag":"","gender":"male","zuchtCanon":"kleinechaote",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":true}
|
||||
]
|
||||
""";
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), littersJson);
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals1);
|
||||
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true); // run 1
|
||||
|
||||
// Run 2 dry-run with papi un-quarantined
|
||||
var animals2 = animals1.Replace("\"conflict\":true", "\"conflict\":false");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), animals2);
|
||||
var dry = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
|
||||
Assert.Equal(1, dry.Litters.ParentFksBackfilled); // predicted but not written
|
||||
var litter = await db.Litters.SingleAsync(l => l.Name == "Wurf B");
|
||||
Assert.Null(litter.FatherId); // not written in dry-run
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||
{
|
||||
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
// One dated litter, one undated litter (blank date field)
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||
[
|
||||
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||
]
|
||||
""");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||
|
||||
using var db = NewDb();
|
||||
|
||||
// First run
|
||||
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(2, r1.Litters.InSource);
|
||||
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||
|
||||
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||
[InlineData("5.3.21", 2021, 3, 5)]
|
||||
@@ -139,7 +566,8 @@ namespace GerbilManager.Tests
|
||||
[
|
||||
{"id":"a1","name":"Kind Eins","dob":"01.02.2020","death":"","gender":null,
|
||||
"farbschlag":"Agouti","farbschlagVariants":["Agouti"],
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"]},"rawGenotype":"aa CC D- ee[f]","unmappedTokens":[]},
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","?"],"E":["e","e^f"],"Sls":["Sl","sl"]},"rawGenotype":"aa CC D- ee[f] WP dea WFNZ","unmappedTokens":[]},
|
||||
"deaf":true,"tags":["WFNZ"],
|
||||
"zucht":"","parentRefs":[],"photos":[],"sourceFiles":["f1"],"conflict":false,
|
||||
"litterRef":{"litterId":"L1","method":"geburtsdatum+eltern","confidence":"hoch"}},
|
||||
{"id":"a2","name":"Streit","dob":"01.01.2019","death":"","gender":null,
|
||||
|
||||
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
217
GerbilManager.Tests/InboxDraftTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Inbox;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung +
|
||||
/// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip
|
||||
/// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben).
|
||||
/// </summary>
|
||||
public class InboxDraftTests
|
||||
{
|
||||
private static Request SampleRequest() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>",
|
||||
FromAddress = "anna.musterfrau@example.de",
|
||||
FromName = "Anna Musterfrau",
|
||||
Subject = "Anfrage: zwei Weibchen?",
|
||||
BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna",
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
// ── Zitat-/Signatur-Stripping ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header()
|
||||
{
|
||||
var body = string.Join('\n',
|
||||
"Hallo, habt ihr Tiere abzugeben?",
|
||||
"> alte zitierte Zeile",
|
||||
"Von: jemand@example.de",
|
||||
"Danke!",
|
||||
"-- ",
|
||||
"Anna Musterfrau",
|
||||
"Musterweg 1");
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped);
|
||||
Assert.Contains("Danke!", stripped);
|
||||
Assert.DoesNotContain("alte zitierte Zeile", stripped);
|
||||
Assert.DoesNotContain("jemand@example.de", stripped);
|
||||
Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab()
|
||||
{
|
||||
var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf";
|
||||
var stripped = DraftReplyService.StripQuotedText(body);
|
||||
|
||||
Assert.Equal("Meine Frage steht oben.", stripped);
|
||||
}
|
||||
|
||||
// ── Prompt-Assembly + Datenminimierung ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(),
|
||||
[new DraftReplyService.ForSaleAnimal("Frieda", "Gold"),
|
||||
new DraftReplyService.ForSaleAnimal("Fine", null)]);
|
||||
|
||||
Assert.Contains("Von: Anna Musterfrau", prompt);
|
||||
Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt);
|
||||
Assert.Contains("zwei junge Weibchen zur Abgabe", prompt);
|
||||
Assert.Contains("- Frieda (Gold)", prompt);
|
||||
Assert.Contains("- Fine", prompt);
|
||||
// Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []);
|
||||
Assert.DoesNotContain("abzugebende Tiere", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten()
|
||||
{
|
||||
var prompt = DraftReplyService.BuildSystemPrompt();
|
||||
Assert.Contains("ENTWERFEN", prompt);
|
||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||
Assert.Contains("Keine Preise", prompt);
|
||||
Assert.Contains("keine Adressen", prompt);
|
||||
}
|
||||
|
||||
// ── 503: unkonfiguriert (Service-Ebene) ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration()
|
||||
{
|
||||
var service = new DraftReplyService(
|
||||
new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))),
|
||||
Options.Create(new AiOptions()));
|
||||
|
||||
var result = await service.DraftAsync(SampleRequest(), []);
|
||||
|
||||
Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status);
|
||||
}
|
||||
|
||||
// ── Endpoint-Round-Trip ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert()
|
||||
{
|
||||
using var factory = new ApiFactory();
|
||||
var id = await SeedRequestAsync(factory);
|
||||
|
||||
var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs()
|
||||
{
|
||||
const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße";
|
||||
var stub = new StubHandler(_ => Canned(canned));
|
||||
using var factory = new ApiFactory
|
||||
{
|
||||
ConfigureTestServices = services =>
|
||||
{
|
||||
services.PostConfigure<AiOptions>(o =>
|
||||
{
|
||||
o.BaseUrl = "https://api.example.com/v1";
|
||||
o.ApiKey = "test";
|
||||
o.Model = "test-model";
|
||||
});
|
||||
services.AddHttpClient<DraftReplyService>()
|
||||
.ConfigurePrimaryHttpMessageHandler(() => stub);
|
||||
},
|
||||
};
|
||||
|
||||
var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true);
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"/api/requests/{id}/draft", null);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString());
|
||||
|
||||
// Persistiert: erneutes GET liefert den Entwurf
|
||||
var again = await client.GetFromJsonAsync<JsonElement>($"/api/requests/{id}");
|
||||
Assert.Equal(canned, again.GetProperty("draftReply").GetString());
|
||||
|
||||
// Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die
|
||||
// Mailadresse der Absenderin NICHT.
|
||||
Assert.NotNull(stub.LastRequestBody);
|
||||
Assert.Contains("zwei junge Weibchen", stub.LastRequestBody);
|
||||
Assert.Contains("Aki", stub.LastRequestBody);
|
||||
Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody);
|
||||
}
|
||||
|
||||
// ── Helfer ─────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var request = SampleRequest();
|
||||
db.Add(request);
|
||||
if (alsoForSaleGerbil)
|
||||
{
|
||||
db.Add(new Gerbil
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Aki",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.ForSale,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return request.Id;
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return respond(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,69 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||
Assert.Contains("NIEMALS Preise", 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: LOCKER RESERVIERT Anna", prompt);
|
||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||
}
|
||||
|
||||
// ── FEAT-14c: Charakterbogen (Traits + Notiz) im Prompt ────────────
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Traits_und_Charakternotiz()
|
||||
{
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "alte Verwaltungsnotiz",
|
||||
Traits: ["zutraulich", "buddelt gern"],
|
||||
CharacterNote: "klettert abends auf die Hand")],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: zutraulich, buddelt gern", prompt);
|
||||
Assert.Contains("Charakter-Notiz: klettert abends auf die Hand", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_bevorzugt_Charakterbogen_vor_generischen_Notizen()
|
||||
{
|
||||
// Traits vorhanden -> die generische Notiz (oft Verwaltungs-Info)
|
||||
// gehört NICHT in den Prompt.
|
||||
var request = new SaleAdRequest(
|
||||
[new SaleAdAnimal("Balu", null, null, "Käfig wurde am 3.5. gereinigt",
|
||||
Traits: ["neugierig"])],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Charakter: neugierig", prompt);
|
||||
Assert.DoesNotContain("Notizen:", prompt);
|
||||
Assert.DoesNotContain("Käfig wurde", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_fällt_ohne_Charakterbogen_auf_Notizen_zurück()
|
||||
{
|
||||
// Kein Charakterbogen (null bzw. leer) -> Notizen wie bisher.
|
||||
var request = new SaleAdRequest(
|
||||
[
|
||||
new SaleAdAnimal("Mysti", null, null, "sehr verschmust", Traits: null),
|
||||
new SaleAdAnimal("Bo", null, null, "mag Kolbenhirse", Traits: []),
|
||||
],
|
||||
"FREI", "");
|
||||
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||
|
||||
Assert.Contains("Notizen: sehr verschmust", prompt);
|
||||
Assert.Contains("Notizen: mag Kolbenhirse", prompt);
|
||||
Assert.DoesNotContain("Charakter:", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_fließende_Prosa_statt_Stichwortliste()
|
||||
{
|
||||
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||
Assert.Contains("Charakter-Eigenschaften", prompt);
|
||||
Assert.Contains("niemals als Aufzählung", prompt);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2024-03-12", "12.03.2024")]
|
||||
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
|
||||
|
||||
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
331
GerbilManager.Tests/SiteRendererTests.cs
Normal file
@@ -0,0 +1,331 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-1: static site renderer — snapshot → HTML file map.
|
||||
/// All tests are pure unit tests (no DB, no HTTP); they build JsonObject
|
||||
/// snapshots directly and assert on rendered HTML.
|
||||
/// </summary>
|
||||
public class SiteRendererTests
|
||||
{
|
||||
// ── Snapshot builder helpers ─────────────────────────────────────────────
|
||||
|
||||
private static JsonObject MakeSnapshot(params (string slug, string title, JsonArray blocks)[] pages)
|
||||
{
|
||||
var navOrder = new JsonArray();
|
||||
var pagesArr = new JsonArray();
|
||||
foreach (var (slug, title, blocks) in pages)
|
||||
{
|
||||
navOrder.Add(slug);
|
||||
pagesArr.Add(new JsonObject
|
||||
{
|
||||
["slug"] = slug,
|
||||
["title"] = title,
|
||||
["seoDescription"] = $"SEO für {title}",
|
||||
["status"] = "Published",
|
||||
["blocks"] = blocks,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = navOrder },
|
||||
["pages"] = pagesArr,
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonArray Blocks(params JsonObject[] blocks)
|
||||
{
|
||||
var arr = new JsonArray();
|
||||
int i = 0;
|
||||
foreach (var b in blocks) { b["order"] = i++; arr.Add(b); }
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonObject HeadingBlock(string text, int level = 1) => new()
|
||||
{
|
||||
["type"] = "Heading",
|
||||
["data"] = new JsonObject { ["text"] = text, ["level"] = level },
|
||||
};
|
||||
|
||||
private static JsonObject RichTextBlock(string md) => new()
|
||||
{
|
||||
["type"] = "RichText",
|
||||
["data"] = new JsonObject { ["markdown"] = md },
|
||||
};
|
||||
|
||||
private static JsonObject ImageBlock(string url, string alt) => new()
|
||||
{
|
||||
["type"] = "Image",
|
||||
["data"] = new JsonObject { ["url"] = url, ["alt"] = alt },
|
||||
};
|
||||
|
||||
private static JsonObject ContactInfoBlock(string name, string email, string phone) => new()
|
||||
{
|
||||
["type"] = "ContactInfo",
|
||||
["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)
|
||||
{
|
||||
var animalArr = new JsonArray();
|
||||
foreach (var (name, farbe, group, photo) in animals)
|
||||
{
|
||||
var photos = new JsonArray();
|
||||
if (photo is not null) photos.Add(photo);
|
||||
animalArr.Add(new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["farbschlag"] = farbe,
|
||||
["group"] = group,
|
||||
["photos"] = photos,
|
||||
["aiSaleText"] = (JsonNode?)null,
|
||||
});
|
||||
}
|
||||
return new JsonObject
|
||||
{
|
||||
["type"] = "AbgabetiereList",
|
||||
["data"] = new JsonObject { ["mode"] = "auto", ["intro"] = intro, ["animals"] = animalArr },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_index_html_for_start_page()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Willkommen"))));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_produces_slug_subfolder_for_non_start_pages()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_includes_css_asset()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
Assert.Contains("site-header", files[SiteRenderer.CssPath]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_is_valid_doctype_and_contains_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Startseite", Blocks(HeadingBlock("Hallo"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.StartsWith("<!DOCTYPE html>", html);
|
||||
Assert.Contains("<title>Startseite |", html);
|
||||
Assert.Contains("lang=\"de\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Page_html_includes_seo_description()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks()));
|
||||
// seoDescription is set to "SEO für Start" by our helper
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("meta name=\"description\"", html);
|
||||
Assert.Contains("SEO für Start", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nav_links_are_rendered_with_all_slugs()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("Abgabetiere", html);
|
||||
Assert.Contains("Kontakt", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_page_nav_link_has_aria_current()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Start", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks()));
|
||||
var abgHtml = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("aria-current=\"page\"", abgHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_block_renders_h_tag_with_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(HeadingBlock("Meine Rennmäuse", 2))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<h2", html);
|
||||
Assert.Contains("Meine Rennmäuse", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_bold_and_paragraph()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("**Hallo** Welt"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<strong>Hallo</strong>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RichText_block_renders_markdown_list()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(RichTextBlock("- Eins\n- Zwei"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("<ul>", html);
|
||||
Assert.Contains("<li>", html);
|
||||
Assert.Contains("Eins", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Image_block_renders_img_with_alt()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "Start", Blocks(ImageBlock("/img/maus.jpg", "Eine Rennmaus"))));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.Contains("src=\"/img/maus.jpg\"", html);
|
||||
Assert.Contains("alt=\"Eine Rennmaus\"", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactInfo_block_renders_name_and_email()
|
||||
{
|
||||
var snap = MakeSnapshot(("kontakt", "Kontakt",
|
||||
Blocks(ContactInfoBlock("Kleine Chaoten", "info@kleine-chaoten.de", "+49 123 456789"))));
|
||||
var html = SiteRenderer.Render(snap)["kontakt/index.html"];
|
||||
Assert.Contains("Kleine Chaoten", html);
|
||||
Assert.Contains("info@kleine-chaoten.de", html);
|
||||
Assert.Contains("+49 123 456789", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_renders_animal_cards_with_name_and_farbschlag()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock("Aktuelle Tiere:", ("Krümel", "CP-Agouti", "Großbecken", "/photos/files/abc.jpg")))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("Krümel", html);
|
||||
Assert.Contains("CP-Agouti", html);
|
||||
Assert.Contains("Großbecken", html);
|
||||
Assert.Contains("/photos/files/abc.jpg", html);
|
||||
Assert.Contains("cms-animal-card", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbgabetiereList_empty_shows_placeholder_text()
|
||||
{
|
||||
var snap = MakeSnapshot(("abgabetiere", "Abgabetiere",
|
||||
Blocks(AbgabetiereBlock(""))));
|
||||
var html = SiteRenderer.Render(snap)["abgabetiere/index.html"];
|
||||
Assert.Contains("cms-abgabe-empty", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Draft_pages_are_excluded_from_output()
|
||||
{
|
||||
var pages = new JsonArray
|
||||
{
|
||||
new JsonObject { ["slug"] = "start", ["title"] = "Start", ["status"] = "Published",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
new JsonObject { ["slug"] = "entwurf", ["title"] = "Entwurf", ["status"] = "Draft",
|
||||
["seoDescription"] = (JsonNode?)null, ["blocks"] = new JsonArray() },
|
||||
};
|
||||
var snap = new JsonObject
|
||||
{
|
||||
["site"] = new JsonObject { ["defaultLocale"] = "de", ["navOrder"] = new JsonArray { "start", "entwurf" } },
|
||||
["pages"] = pages,
|
||||
};
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.False(files.ContainsKey("entwurf/index.html"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Html_encoding_prevents_xss_in_page_title()
|
||||
{
|
||||
var snap = MakeSnapshot(("start", "<script>alert(1)</script>", Blocks()));
|
||||
var html = SiteRenderer.Render(snap)["index.html"];
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
Assert.Contains("<script>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_six_seeded_pages_produces_expected_paths()
|
||||
{
|
||||
var snap = MakeSnapshot(
|
||||
("start", "Startseite", Blocks()),
|
||||
("ueber-die-zucht", "Über die Zucht", Blocks()),
|
||||
("abgabetiere", "Abgabetiere", Blocks(AbgabetiereBlock(""))),
|
||||
("abgabebedingungen", "Abgabebedingungen", Blocks()),
|
||||
("farben-genetik", "Farben & Genetik", Blocks()),
|
||||
("kontakt", "Kontakt", Blocks()));
|
||||
var files = SiteRenderer.Render(snap);
|
||||
Assert.True(files.ContainsKey("index.html"));
|
||||
Assert.True(files.ContainsKey("ueber-die-zucht/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabetiere/index.html"));
|
||||
Assert.True(files.ContainsKey("abgabebedingungen/index.html"));
|
||||
Assert.True(files.ContainsKey("farben-genetik/index.html"));
|
||||
Assert.True(files.ContainsKey("kontakt/index.html"));
|
||||
Assert.True(files.ContainsKey(SiteRenderer.CssPath));
|
||||
// 6 pages + 1 CSS
|
||||
Assert.Equal(7, files.Count);
|
||||
}
|
||||
|
||||
// ── Markdown tests ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("**fett**", "<strong>fett</strong>")]
|
||||
[InlineData("*kursiv*", "<em>kursiv</em>")]
|
||||
[InlineData("`code`", "<code>code</code>")]
|
||||
public void Markdown_inline_renders_correctly(string md, string expectedFragment)
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml(md);
|
||||
Assert.Contains(expectedFragment, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_heading_renders_h_tag()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("## Abschnitt");
|
||||
Assert.Contains("<h2>Abschnitt</h2>", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Markdown_xss_text_is_encoded()
|
||||
{
|
||||
var html = SiteRenderer.MarkdownToHtml("<script>alert(1)</script>");
|
||||
Assert.DoesNotContain("<script>", html);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WEB-1: smoke-test the /api/render-site endpoint via ApiFactory.</summary>
|
||||
public class RenderSiteEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public RenderSiteEndpointTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task RenderSite_returns_file_list_with_css_and_pages()
|
||||
{
|
||||
var json = await _client.GetStringAsync("/api/render-site");
|
||||
var files = JsonSerializer.Deserialize<List<RenderEntry>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
Assert.Contains(files, f => f.Path == SiteRenderer.CssPath);
|
||||
Assert.Contains(files, f => f.Path == "index.html");
|
||||
Assert.All(files, f => Assert.True(f.Size > 0));
|
||||
}
|
||||
|
||||
private record RenderEntry(string Path, int Size);
|
||||
}
|
||||
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
99
GerbilManagerWebAPI/Ai/OpenAiChatClient.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Ai
|
||||
{
|
||||
public enum AiCallStatus
|
||||
{
|
||||
Ok,
|
||||
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
|
||||
NotConfigured,
|
||||
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
|
||||
UpstreamError,
|
||||
}
|
||||
|
||||
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the
|
||||
/// reply-draft (and future AI features) reuse the SAME wire implementation:
|
||||
/// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers
|
||||
/// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK.
|
||||
/// Configuration stays the single AI section (AiOptions, env-only).
|
||||
/// </summary>
|
||||
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public async Task<AiCallResult> CompleteAsync(
|
||||
string systemPrompt, string userPrompt, CancellationToken ct = default)
|
||||
{
|
||||
var ai = options.Value;
|
||||
if (!ai.IsConfigured)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.NotConfigured, null,
|
||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
||||
}
|
||||
|
||||
var payload = new ChatRequest(
|
||||
Model: ai.Model!,
|
||||
Messages:
|
||||
[
|
||||
new ChatMessage("system", systemPrompt),
|
||||
new ChatMessage("user", userPrompt),
|
||||
],
|
||||
Temperature: 0.7);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(httpRequest, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
|
||||
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
"KI-Antwort enthielt keinen Text.")
|
||||
: new AiCallResult(AiCallStatus.Ok, text);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new AiCallResult(AiCallStatus.UpstreamError, null,
|
||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
||||
|
||||
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
|
||||
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
|
||||
|
||||
internal sealed record ChatMessage(string Role, string Content);
|
||||
|
||||
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
||||
|
||||
internal sealed record ChatChoice(ChatMessage? Message);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ public class ApplicationContext : DbContext
|
||||
e.Property(g => g.Gender).HasConversion<string>();
|
||||
e.Property(g => g.Status).HasConversion<string>();
|
||||
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||
@@ -234,7 +237,6 @@ public class ApplicationContext : DbContext
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Schwarzschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
||||
@@ -254,55 +256,44 @@ public class ApplicationContext : DbContext
|
||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam (Marder-Hell)", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
||||
("Schimmel (Orangeschimmel)", "AA CC DD efef GG PP spsp rere"),
|
||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
||||
("Agouti CP-Hell", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("Blaufuchs CP", "aa cchmcchm DD ee gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
("Polarfuchs-Hell CP", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
||||
("Siam (Marder-Hell) dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
||||
("Marder dd", "aa cchmcchm dd EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmcchm DD EE gg PP spsp rere"),
|
||||
("Silberagouti dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
||||
("Silberagouti-Hell dd CP", "AA cchmcchm dd EE gg PP spsp rere"),
|
||||
("Agouti dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
||||
("Agouti-Hell dd CP", "AA cchmcchm dd EE GG PP spsp rere"),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
||||
("Zobel dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
||||
("Kohlfuchs CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
||||
("Algierfuchs CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("Silberagouti CP", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("Agouti CP", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("Algierfuchs-Hell CP", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("Kohlfuchs,hell CP", "aa cchmcchm DD ee GG PP spsp rere"),
|
||||
("Polarfuchs CP", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
||||
("Zobel-Hell dd", "aa cchmcchm dd EE gg PP spsp rere"),
|
||||
("Kohlfuchsschimmel CP", "aa cchmcchm DD efef GG PP spsp rere"),
|
||||
("Blaufuchs dd", "aa CC dd ee gg PP spsp rere"),
|
||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
||||
("CP-Fuchs", "AA cchmcchm 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-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
||||
};
|
||||
|
||||
var rows = new ColorVariety[catalog.Length];
|
||||
|
||||
484
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
484
GerbilManagerWebAPI/Cms/SiteRenderer.cs
Normal file
@@ -0,0 +1,484 @@
|
||||
// System.Net.WebUtility intentionally NOT used — its HtmlEncode encodes all non-ASCII
|
||||
// characters as named entities (e.g. ä→ä), which is wrong for UTF-8 pages.
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-1: Deterministic static site renderer. Consumes a CMS snapshot (from
|
||||
/// SiteSnapshotService.BuildAsync) and produces a flat path→content dictionary
|
||||
/// ready for upload to Cloudflare Pages. No external dependencies; Markdown
|
||||
/// support handles the common subset used in RichText blocks.
|
||||
/// </summary>
|
||||
public static class SiteRenderer
|
||||
{
|
||||
public const string SiteName = "Kleine Chaoten";
|
||||
public const string CssPath = "assets/site.css";
|
||||
|
||||
// ── Public entry point ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Renders the snapshot to a flat file map (relative-path → file-content).
|
||||
/// Only <c>Published</c> pages are included. The "start" page maps to
|
||||
/// <c>index.html</c>; all others to <c>{slug}/index.html</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> Render(JsonObject snapshot)
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
files[CssPath] = SiteCss();
|
||||
|
||||
var navSlugs = NavSlugs(snapshot);
|
||||
var pages = snapshot["pages"] as JsonArray ?? [];
|
||||
|
||||
foreach (var node in pages)
|
||||
{
|
||||
if (node is not JsonObject page) continue;
|
||||
if (Str(page["status"]) != "Published") continue;
|
||||
|
||||
var slug = Str(page["slug"]) ?? "page";
|
||||
var title = Str(page["title"]) ?? slug;
|
||||
var seoDes = Str(page["seoDescription"]);
|
||||
var blocks = page["blocks"] as JsonArray ?? [];
|
||||
|
||||
var cssHref = slug == "start" ? CssPath : $"../{CssPath}";
|
||||
var body = RenderBlocks(blocks, slug);
|
||||
var html = PageHtml(title, seoDes, body, navSlugs, slug, cssHref);
|
||||
var path = slug == "start" ? "index.html" : $"{slug}/index.html";
|
||||
files[path] = html;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Block rendering ──────────────────────────────────────────────────
|
||||
|
||||
private static string RenderBlocks(JsonArray blocks, string currentSlug)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var node in blocks.OrderBy(b => b?["order"]?.GetValue<int>() ?? 0))
|
||||
{
|
||||
if (node is not JsonObject b) continue;
|
||||
var type = Str(b["type"]);
|
||||
var data = b["data"] as JsonObject ?? [];
|
||||
sb.Append(type switch
|
||||
{
|
||||
"Heading" => RenderHeading(data),
|
||||
"RichText" => RenderRichText(data),
|
||||
"Image" => RenderImage(data),
|
||||
"Gallery" => RenderGallery(data),
|
||||
"ContactInfo" => RenderContactInfo(data),
|
||||
"AbgabetiereList" => RenderAbgabetiereList(data),
|
||||
_ => string.Empty,
|
||||
});
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderHeading(JsonObject d)
|
||||
{
|
||||
var level = d["level"]?.GetValue<int>() ?? 2;
|
||||
level = Math.Clamp(level, 1, 6);
|
||||
var text = H(Str(d["text"]) ?? "");
|
||||
return $"\n<h{level} class=\"cms-heading\">{text}</h{level}>\n";
|
||||
}
|
||||
|
||||
private static string RenderRichText(JsonObject d)
|
||||
{
|
||||
var md = Str(d["markdown"]) ?? "";
|
||||
return $"\n<div class=\"cms-richtext\">{MarkdownToHtml(md)}</div>\n";
|
||||
}
|
||||
|
||||
private static string RenderImage(JsonObject d)
|
||||
{
|
||||
var url = H(Str(d["url"]) ?? "");
|
||||
var alt = H(Str(d["alt"]) ?? "");
|
||||
return $"\n<figure class=\"cms-image\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n";
|
||||
}
|
||||
|
||||
private static string RenderGallery(JsonObject d)
|
||||
{
|
||||
var images = d["images"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<div class=\"cms-gallery\">\n");
|
||||
foreach (var img in images)
|
||||
{
|
||||
if (img is not JsonObject o) continue;
|
||||
var url = H(Str(o["url"]) ?? "");
|
||||
var alt = H(Str(o["alt"]) ?? "");
|
||||
sb.Append($" <figure class=\"cms-gallery-item\"><img src=\"{url}\" alt=\"{alt}\" loading=\"lazy\"></figure>\n");
|
||||
}
|
||||
sb.Append("</div>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderContactInfo(JsonObject d)
|
||||
{
|
||||
var sb = new StringBuilder("\n<address class=\"cms-contact\">\n");
|
||||
void Row(string? val, string icon) { if (!string.IsNullOrWhiteSpace(val)) sb.AppendLine($" <span class=\"cms-contact-row\">{icon} {H(val)}</span>"); }
|
||||
Row(Str(d["name"]), "👤");
|
||||
Row(Str(d["address"]), "📍");
|
||||
var phone = Str(d["phone"]);
|
||||
if (!string.IsNullOrWhiteSpace(phone))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">📞 <a href=\"tel:{H(phone)}\">{H(phone)}</a></span>");
|
||||
var email = Str(d["email"]);
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
sb.AppendLine($" <span class=\"cms-contact-row\">✉️ <a href=\"mailto:{H(email)}\">{H(email)}</a></span>");
|
||||
sb.Append("</address>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderAbgabetiereList(JsonObject d)
|
||||
{
|
||||
var intro = Str(d["intro"]);
|
||||
var animals = d["animals"] as JsonArray ?? [];
|
||||
var sb = new StringBuilder("\n<section class=\"cms-abgabe\">\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(intro))
|
||||
sb.Append($" <p class=\"cms-abgabe-intro\">{H(intro)}</p>\n");
|
||||
|
||||
if (animals.Count == 0)
|
||||
{
|
||||
sb.Append(" <p class=\"cms-abgabe-empty\">Zurzeit stehen keine Tiere zur Abgabe bereit.</p>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" <div class=\"cms-animal-grid\">\n");
|
||||
foreach (var node in animals)
|
||||
{
|
||||
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");
|
||||
|
||||
// Profile photo (first photo)
|
||||
var firstPhoto = photos.FirstOrDefault()?.GetValue<string>();
|
||||
if (!string.IsNullOrEmpty(firstPhoto))
|
||||
sb.Append($" <img class=\"cms-animal-photo\" src=\"{H(firstPhoto)}\" alt=\"{name}\" loading=\"lazy\">\n");
|
||||
|
||||
sb.Append(" <div class=\"cms-animal-info\">\n");
|
||||
sb.Append($" <h3 class=\"cms-animal-name\">{name}</h3>\n");
|
||||
if (!string.IsNullOrEmpty(farbe))
|
||||
sb.Append($" <p class=\"cms-animal-farbe\">{farbe}</p>\n");
|
||||
if (!string.IsNullOrWhiteSpace(group))
|
||||
sb.Append($" <p class=\"cms-animal-group\">Gruppe: {H(group)}</p>\n");
|
||||
if (!string.IsNullOrWhiteSpace(saleText))
|
||||
sb.Append($" <p class=\"cms-animal-text\">{H(saleText)}</p>\n");
|
||||
sb.Append(" </div>\n");
|
||||
sb.Append(" </article>\n");
|
||||
}
|
||||
sb.Append(" </div>\n");
|
||||
}
|
||||
|
||||
sb.Append("</section>\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Page template ────────────────────────────────────────────────────
|
||||
|
||||
private static string PageHtml(
|
||||
string title, string? seoDesc, string body,
|
||||
IReadOnlyList<string> navSlugs, string currentSlug, string cssHref)
|
||||
{
|
||||
var fullTitle = H($"{title} | {SiteName}");
|
||||
var metaDesc = seoDesc is null ? ""
|
||||
: $"\n <meta name=\"description\" content=\"{H(seoDesc)}\">";
|
||||
|
||||
var navBase = currentSlug == "start" ? "" : "../";
|
||||
var navHtml = BuildNav(navSlugs, currentSlug, navBase);
|
||||
|
||||
return $"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">{metaDesc}
|
||||
<title>{fullTitle}</title>
|
||||
<link rel="stylesheet" href="{H(cssHref)}">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a href="{navBase}index.html" class="site-logo">🐭 {H(SiteName)}</a>
|
||||
<nav class="site-nav" aria-label="Hauptnavigation">
|
||||
{navHtml}
|
||||
</nav>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
{body}
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<p>© {H(SiteName)} — Mongolische Rennmäuse</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string BuildNav(IReadOnlyList<string> slugs, string current, string navBase)
|
||||
{
|
||||
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["start"] = "Start",
|
||||
["ueber-die-zucht"] = "Über die Zucht",
|
||||
["abgabetiere"] = "Abgabetiere",
|
||||
["abgabebedingungen"] = "Abgabebedingungen",
|
||||
["farben-genetik"] = "Farben & Genetik",
|
||||
["kontakt"] = "Kontakt",
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
var label = labels.TryGetValue(slug, out var l) ? l : slug;
|
||||
var href = slug == "start" ? $"{navBase}index.html" : $"{navBase}{slug}/index.html";
|
||||
var active = slug == current ? " aria-current=\"page\"" : "";
|
||||
sb.AppendLine($" <a href=\"{H(href)}\" class=\"site-nav-link\"{active}>{H(label)}</a>");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
// ── Minimal Markdown → HTML ──────────────────────────────────────────
|
||||
|
||||
internal static string MarkdownToHtml(string md)
|
||||
{
|
||||
if (string.IsNullOrEmpty(md)) return "";
|
||||
var lines = md.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
|
||||
var sb = new StringBuilder();
|
||||
var inList = false;
|
||||
var isOrdered = false;
|
||||
|
||||
void CloseList()
|
||||
{
|
||||
if (!inList) return;
|
||||
sb.AppendLine(isOrdered ? "</ol>" : "</ul>");
|
||||
inList = false;
|
||||
}
|
||||
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw;
|
||||
|
||||
// ATX headings
|
||||
var hm = Regex.Match(line, @"^(#{1,6})\s+(.+)$");
|
||||
if (hm.Success)
|
||||
{
|
||||
CloseList();
|
||||
var lvl = hm.Groups[1].Length;
|
||||
sb.AppendLine($"<h{lvl}>{InlineHtml(hm.Groups[2].Value)}</h{lvl}>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
var ulm = Regex.Match(line, @"^[-*+]\s+(.+)$");
|
||||
if (ulm.Success)
|
||||
{
|
||||
if (!inList || isOrdered) { CloseList(); sb.AppendLine("<ul>"); inList = true; isOrdered = false; }
|
||||
sb.AppendLine($"<li>{InlineHtml(ulm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
var olm = Regex.Match(line, @"^\d+\.\s+(.+)$");
|
||||
if (olm.Success)
|
||||
{
|
||||
if (!inList || !isOrdered) { CloseList(); sb.AppendLine("<ol>"); inList = true; isOrdered = true; }
|
||||
sb.AppendLine($"<li>{InlineHtml(olm.Groups[1].Value)}</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
CloseList();
|
||||
|
||||
// Blank line
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
sb.AppendLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (Regex.IsMatch(line, @"^(-{3,}|\*{3,}|_{3,})$"))
|
||||
{
|
||||
sb.AppendLine("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
sb.AppendLine($"<p>{InlineHtml(line)}</p>");
|
||||
}
|
||||
|
||||
CloseList();
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string InlineHtml(string text)
|
||||
{
|
||||
// First HTML-encode, then selectively un-encode our safe inline patterns
|
||||
// to avoid encoding the tags we are about to add.
|
||||
// Order matters: bold before italic.
|
||||
text = H(text);
|
||||
// Bold **text**
|
||||
text = Regex.Replace(text, @"\*\*(.+?)\*\*", "<strong>$1</strong>");
|
||||
// Italic *text* (single, not preceded/followed by *)
|
||||
text = Regex.Replace(text, @"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", "<em>$1</em>");
|
||||
// Inline code `text`
|
||||
text = Regex.Replace(text, @"`(.+?)`", "<code>$1</code>");
|
||||
// Links [text](url) — note: url is already HTML-encoded by H()
|
||||
text = Regex.Replace(text, @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>");
|
||||
return text;
|
||||
}
|
||||
|
||||
// ── CSS ──────────────────────────────────────────────────────────────
|
||||
|
||||
internal static string SiteCss() => """
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--color-bg: #fdf8f3;
|
||||
--color-surface: #fff;
|
||||
--color-text: #2c2c2c;
|
||||
--color-muted: #6b6b6b;
|
||||
--color-accent: #c0392b;
|
||||
--color-border: #e0d8d0;
|
||||
--font-body: system-ui, sans-serif;
|
||||
--max-w: 860px;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.65;
|
||||
}
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img { max-width: 100%; height: auto; display: block; }
|
||||
|
||||
/* ── Header ── */
|
||||
.site-header {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: .75rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .5rem 1.5rem;
|
||||
}
|
||||
.site-logo {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.site-nav { display: flex; flex-wrap: wrap; gap: .25rem .75rem; }
|
||||
.site-nav-link {
|
||||
font-size: .9rem;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
.site-main {
|
||||
max-width: var(--max-w);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem 3rem;
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
font-size: .85rem;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
/* ── CMS blocks ── */
|
||||
.cms-heading { margin: 1.5rem 0 .5rem; }
|
||||
h1.cms-heading { font-size: 1.8rem; }
|
||||
h2.cms-heading { font-size: 1.4rem; }
|
||||
.cms-richtext { margin: 1rem 0; }
|
||||
.cms-richtext p { margin-bottom: .75rem; }
|
||||
.cms-richtext ul, .cms-richtext ol { margin: .5rem 0 .75rem 1.5rem; }
|
||||
.cms-richtext li { margin-bottom: .3rem; }
|
||||
.cms-image { margin: 1.5rem 0; }
|
||||
.cms-image img { border-radius: 6px; }
|
||||
.cms-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: .75rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.cms-gallery-item img { border-radius: 4px; aspect-ratio: 1; object-fit: cover; }
|
||||
.cms-contact {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.cms-contact-row { display: flex; gap: .5rem; align-items: flex-start; }
|
||||
|
||||
/* ── Abgabetiere ── */
|
||||
.cms-abgabe { margin: 1rem 0; }
|
||||
.cms-abgabe-intro { margin-bottom: 1.25rem; font-size: 1.05rem; }
|
||||
.cms-abgabe-empty { color: var(--color-muted); font-style: italic; }
|
||||
.cms-animal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.cms-animal-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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) {
|
||||
h1.cms-heading { font-size: 1.4rem; }
|
||||
.cms-animal-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
""";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private static IReadOnlyList<string> NavSlugs(JsonObject snapshot)
|
||||
{
|
||||
var navArr = snapshot["site"]?["navOrder"] as JsonArray;
|
||||
if (navArr is null) return [];
|
||||
return navArr.Select(n => n?.GetValue<string>()).Where(s => s is not null).ToList()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML-encode only the 5 HTML special characters. Non-ASCII characters (e.g. German
|
||||
/// umlauts) are left as-is — the page declares UTF-8, so no entity encoding needed.
|
||||
/// </summary>
|
||||
private static string H(string? s)
|
||||
{
|
||||
if (s is null) return "";
|
||||
return s.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
||||
/// <summary>Extract a string value from a JsonNode; returns null if not a string.</summary>
|
||||
private static string? Str(JsonNode? node) =>
|
||||
node is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote);
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -75,7 +77,9 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? ExternalRef,
|
||||
string? OriginBreeder,
|
||||
List<string>? CharacterTraits,
|
||||
string? CharacterNote);
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool? IsResident);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
|
||||
@@ -24,6 +24,32 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
api.MapGet("/site-snapshot", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await new SiteSnapshotService(db).BuildAsync()));
|
||||
|
||||
// ---- static render dry-run (WEB-1: returns rendered file map as JSON) ----
|
||||
api.MapGet("/render-site", async (ApplicationContext db) =>
|
||||
{
|
||||
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||
var files = SiteRenderer.Render(snapshot);
|
||||
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 ----
|
||||
api.MapGet("/pages", async (ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Pages.AsNoTracking().OrderBy(p => p.Slug)
|
||||
@@ -143,6 +169,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
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) =>
|
||||
new(b.Id, b.Order, b.Type, JsonNode.Parse(string.IsNullOrWhiteSpace(b.Data) ? "{}" : b.Data));
|
||||
|
||||
|
||||
@@ -97,12 +97,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
g.OriginBreeder = i.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||
g.CharacterNote = i.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g) => new(
|
||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||
g.CharacterTraits, g.CharacterNote);
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/draft — INBOX-2: KI-Antwortentwurf. NUR Entwurf
|
||||
// (human-in-the-loop, Versand ist /send). 503 AiKeyMissing solange die
|
||||
// AI-Sektion unkonfiguriert ist (gleiches UI-Muster wie sale-ad).
|
||||
api.MapPost("/requests/{id:guid}/draft", async Task<IResult> (
|
||||
Guid id, DraftReplyService drafter, ApplicationContext db, CancellationToken ct) =>
|
||||
{
|
||||
var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
if (r is null) return TypedResults.NotFound();
|
||||
|
||||
// Datenminimierung: NUR die ForSale-Liste (Name + Farbschlag) geht
|
||||
// zusätzlich zum Anfragetext an den Anbieter (arch §privacy).
|
||||
var forSale = await db.Gerbils.AsNoTracking()
|
||||
.Where(g => g.Status == GerbilStatus.ForSale)
|
||||
.OrderBy(g => g.Name)
|
||||
.Select(g => new DraftReplyService.ForSaleAnimal(g.Name, g.ColorVariety!.Name))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var result = await drafter.DraftAsync(r, forSale, ct);
|
||||
if (result.Status == Ai.AiCallStatus.NotConfigured)
|
||||
return Results.Json(new { code = "AiKeyMissing", message = result.Error }, statusCode: 503);
|
||||
if (result.Status != Ai.AiCallStatus.Ok)
|
||||
return Results.Json(new { code = "AiUpstreamError", message = result.Error }, statusCode: 502);
|
||||
|
||||
r.DraftReply = result.Text;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return TypedResults.Ok(ToDto(r));
|
||||
});
|
||||
|
||||
// POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered
|
||||
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
|
||||
Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) =>
|
||||
|
||||
@@ -16,11 +16,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
public List<string> FarbschlagVariants { get; set; } = new();
|
||||
public SourceGenotype Genotype { get; set; } = new();
|
||||
public string Zucht { get; set; } = "";
|
||||
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
|
||||
public List<SourceParentRef> ParentRefs { get; set; } = new();
|
||||
public List<string> Photos { get; set; } = new();
|
||||
public List<string> SourceFiles { get; set; } = new();
|
||||
public bool Conflict { get; set; }
|
||||
public SourceLitterRef? LitterRef { get; set; }
|
||||
|
||||
// GEN-3b normalization: hearing/deaf phenotype flag (null = not stated) and
|
||||
// provenance/breeding tags (WFNZ/RV/GV/DP) — neither is genotype.
|
||||
public bool? Deaf { get; set; }
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
// Set by extract.py when a human conflict-decision (conflict-decisions.json) un-quarantined
|
||||
// this animal (its genotype/farbschlag are then authoritative). For reporting.
|
||||
public bool ResolvedByDecision { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SourceGenotype
|
||||
@@ -34,7 +44,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Dob { get; set; } = "";
|
||||
public string RoleGuess { get; set; } = "";
|
||||
public string RoleGuess { get; set; } = ""; // "father" | "mother"
|
||||
public string Method { get; set; } = ""; // e.g. "chart-position"
|
||||
public string Confidence { get; set; } = ""; // "hoch" | "medium" | "niedrig"
|
||||
}
|
||||
|
||||
public sealed class SourceLitterRef
|
||||
@@ -66,9 +78,16 @@ namespace GerbilManagerWebAPI.Import
|
||||
AnimalSummary Animals,
|
||||
PhotoSummary Photos,
|
||||
IReadOnlyList<string> Samples,
|
||||
IReadOnlyList<string> Notes);
|
||||
IReadOnlyList<string> Notes,
|
||||
ResidencySummary? Residency = null);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported);
|
||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||
int ParentFksBackfilled = 0, int WithoutDate = 0);
|
||||
|
||||
public sealed record AnimalSummary(
|
||||
int InSource,
|
||||
@@ -77,7 +96,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
int FarbschlagMatched,
|
||||
int FarbschlagUnmatched,
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined);
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
|
||||
@@ -94,11 +94,17 @@ namespace GerbilManagerWebAPI.Import
|
||||
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) ----
|
||||
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
|
||||
foreach (var sl in litters)
|
||||
{
|
||||
var date = ParseDate(sl.Date);
|
||||
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
@@ -106,19 +112,19 @@ namespace GerbilManagerWebAPI.Import
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
littersCreated++;
|
||||
if (execute && date is DateOnly d)
|
||||
if (execute)
|
||||
{
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Date = d,
|
||||
Date = date.Value,
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
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}");
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
@@ -128,68 +134,238 @@ namespace GerbilManagerWebAPI.Import
|
||||
int photosAttached = 0, photosMissing = 0;
|
||||
var createdAnimalByName = new Dictionary<string, Guid>(); // normalized name -> gerbil id (for litter back-link)
|
||||
|
||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||
var existingRows = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
||||
var gidByNameDob = new Dictionary<string, Guid>();
|
||||
foreach (var g in existingRows)
|
||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||
|
||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||
var plan = new List<AnimalPlan>();
|
||||
foreach (var a in loadable)
|
||||
{
|
||||
if (existingGerbilSet.Contains(a.Id)) { animalsExisting++; continue; }
|
||||
animalsCreated++;
|
||||
bool exists = existingGerbilSet.Contains(a.Id);
|
||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
||||
|
||||
Guid? litterId = null;
|
||||
Guid? wurfLitterId = null;
|
||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||
{
|
||||
litterId = lid;
|
||||
linked++;
|
||||
}
|
||||
wurfLitterId = lid;
|
||||
|
||||
Guid? colorVarietyId = null;
|
||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
foreach (var fb in fbCandidates)
|
||||
{
|
||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||
{ colorVarietyId = vid; break; }
|
||||
}
|
||||
if (colorVarietyId is null) fbUnmatched++; else fbMatched++;
|
||||
|
||||
var gender = InferGender(a, sireNames, damNames);
|
||||
var gid = Guid.NewGuid();
|
||||
var norm = Normalize(StripZucht(a.Name));
|
||||
if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid);
|
||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||
|
||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
||||
}
|
||||
|
||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||
// any animal that has no Wurfchronik litter and isn't already litter-linked. Siblings
|
||||
// (same father+mother+dob) share one derived litter. Computed for dry-run counts too.
|
||||
int parentLinksAdded = 0, derivedLitters = 0;
|
||||
var synthLitterForGid = new Dictionary<Guid, Guid>(); // offspring gid -> synth litter id
|
||||
var synthLitters = new Dictionary<string, SynthLitter>(); // parents+date key -> synth litter
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (p.WurfLitterId is not null || p.CurrentLitterId is not null) continue;
|
||||
if (p.A.ParentRefs is not { Count: > 0 }) continue;
|
||||
var father = ResolveParentGid(p.A, "father", gidByNameDob);
|
||||
var mother = ResolveParentGid(p.A, "mother", gidByNameDob);
|
||||
if (father is null && mother is null) continue; // nothing resolvable to link
|
||||
var dob = ParseDate(p.A.Dob);
|
||||
var conf = p.A.ParentRefs.FirstOrDefault()?.Confidence ?? "medium";
|
||||
var key = $"{father}|{mother}|{dob:yyyy-MM-dd}";
|
||||
if (!synthLitters.TryGetValue(key, out var sl))
|
||||
{
|
||||
sl = new SynthLitter(Guid.NewGuid(), father, mother, dob, conf);
|
||||
synthLitters[key] = sl;
|
||||
derivedLitters++;
|
||||
}
|
||||
synthLitterForGid[p.Gid] = sl.Id;
|
||||
parentLinksAdded++;
|
||||
}
|
||||
|
||||
// FK-INTEGRITY (PEDIGREE-LINK bug fix): a litter's Father/MotherId must resolve to a
|
||||
// gerbil that is created-or-existing, or Postgres throws FK_Litters_Gerbils_*. Compute
|
||||
// the persisted set (existing DB rows + this run's loadable animals) and drop any parent
|
||||
// FK that isn't in it; SKIP a derived litter whose BOTH parents are unresolvable (its
|
||||
// offspring then load with LitterId=null — still better than 'unbekannt' won't regress).
|
||||
// This runs in dry-run too, so a green dry-run GUARANTEES /import/execute won't FK-fault.
|
||||
var persisted = new HashSet<Guid>(existingRows.Select(r => r.Id));
|
||||
foreach (var p in plan) persisted.Add(p.Gid);
|
||||
int litterParentFksDropped = 0, derivedLittersSkipped = 0;
|
||||
foreach (var key in synthLitters.Keys.ToList())
|
||||
{
|
||||
var sl = synthLitters[key];
|
||||
var f = sl.Father is Guid gf && persisted.Contains(gf) ? sl.Father : null;
|
||||
var m = sl.Mother is Guid gm && persisted.Contains(gm) ? sl.Mother : null;
|
||||
if (sl.Father is not null && f is null) litterParentFksDropped++;
|
||||
if (sl.Mother is not null && m is null) litterParentFksDropped++;
|
||||
if (f is null && m is null)
|
||||
{
|
||||
derivedLittersSkipped++; derivedLitters--;
|
||||
foreach (var gid in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid.Remove(gid);
|
||||
synthLitters.Remove(key);
|
||||
continue;
|
||||
}
|
||||
synthLitters[key] = sl with { Father = f, Mother = m };
|
||||
}
|
||||
parentLinksAdded = synthLitterForGid.Count;
|
||||
|
||||
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters,
|
||||
// each FK guarded by the persisted set. Used by residency rule (b) below; augmented with
|
||||
// existing DB litters under execute.
|
||||
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
|
||||
foreach (var sl in synthLitters.Values)
|
||||
litterParents[sl.Id] = (sl.Father, sl.Mother);
|
||||
foreach (var sl in litters)
|
||||
if (litterIdMap.TryGetValue(sl.Id, out var lid))
|
||||
{
|
||||
Guid? f = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) && persisted.Contains(fid) ? fid : null;
|
||||
Guid? m = createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) && persisted.Contains(mid) ? mid : null;
|
||||
litterParents[lid] = (f, m);
|
||||
}
|
||||
|
||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
||||
if (execute)
|
||||
{
|
||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||
var existingLitterRows = await _db.Litters
|
||||
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
|
||||
var litterByParentsDate = new Dictionary<string, Guid>();
|
||||
foreach (var l in existingLitterRows)
|
||||
{
|
||||
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
|
||||
litterParents[l.Id] = (l.FatherId, l.MotherId);
|
||||
}
|
||||
|
||||
foreach (var sl in synthLitters.Values.ToList())
|
||||
{
|
||||
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
|
||||
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
|
||||
{
|
||||
// remap offspring to the existing litter; don't create a duplicate.
|
||||
foreach (var g in synthLitterForGid.Where(kv => kv.Value == sl.Id).Select(kv => kv.Key).ToList())
|
||||
synthLitterForGid[g] = existingId;
|
||||
derivedLitters--;
|
||||
continue;
|
||||
}
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = sl.Id,
|
||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||
Date = sl.Date ?? default,
|
||||
FatherId = sl.Father,
|
||||
MotherId = sl.Mother,
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
// NOTE: no SaveChanges here — staged with the gerbils below.
|
||||
}
|
||||
|
||||
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
||||
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
|
||||
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
|
||||
static bool ClanCanon(string? zc) =>
|
||||
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
|
||||
var resident = new HashSet<Guid>();
|
||||
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
|
||||
int residentByA = resident.Count, flippedByParentRule = 0;
|
||||
foreach (var p in plan)
|
||||
{
|
||||
if (!ClanCanon(p.A.ZuchtCanon)) continue;
|
||||
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
|
||||
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
|
||||
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
|
||||
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
|
||||
}
|
||||
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
|
||||
int externalTotal = plan.Count - residentTotal;
|
||||
|
||||
foreach (var p in plan)
|
||||
{
|
||||
Guid? litterId = p.WurfLitterId
|
||||
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
|
||||
if (litterId is not null) linked++;
|
||||
bool isResident = resident.Contains(p.Gid);
|
||||
|
||||
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
|
||||
|
||||
if (samples.Count < 16)
|
||||
samples.Add($"Tier: {a.Name} (*{a.Dob}), Genotyp {ComposeGenotype(a.Genotype)}"
|
||||
+ (litterId is not null ? ", Wurf-verknüpft" : "")
|
||||
+ (colorVarietyId is not null ? $", Farbschlag „{a.Farbschlag}\"" : ""));
|
||||
samples.Add($"Tier: {p.A.Name} (*{p.A.Dob}), Genotyp {ComposeGenotype(p.A.Genotype)}"
|
||||
+ (litterId is not null ? (p.WurfLitterId is not null ? ", Wurf-verknüpft" : ", Eltern aus Diagramm") : "")
|
||||
+ (p.ColorVarietyId is not null ? $", Farbschlag „{p.A.Farbschlag}\"" : ""));
|
||||
|
||||
if (p.Exists)
|
||||
{
|
||||
animalsExisting++;
|
||||
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
|
||||
if (row is not null)
|
||||
{
|
||||
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
|
||||
row.IsResident = isResident;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
animalsCreated++;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
{
|
||||
Id = gid,
|
||||
Name = a.Name,
|
||||
Gender = gender,
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
DateOfBirth = ParseDate(a.Dob),
|
||||
DateOfDeath = ParseDate(a.Death),
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
ColorVarietyId = colorVarietyId,
|
||||
Genotype = ComposeGenotype(a.Genotype),
|
||||
ColorVarietyId = p.ColorVarietyId,
|
||||
Genotype = ComposeGenotype(p.A.Genotype),
|
||||
IsDeaf = p.A.Deaf,
|
||||
IsResident = isResident,
|
||||
ImportSource = ImportSourceTag,
|
||||
ExternalRef = a.Id,
|
||||
OriginBreeder = string.IsNullOrWhiteSpace(a.Zucht) ? null : a.Zucht.Trim(),
|
||||
ExternalRef = p.A.Id,
|
||||
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
|
||||
RawImportData = JsonSerializer.Serialize(new
|
||||
{
|
||||
a.Genotype.RawGenotype,
|
||||
a.Genotype.UnmappedTokens,
|
||||
a.Zucht,
|
||||
a.SourceFiles,
|
||||
FarbschlagRaw = a.Farbschlag,
|
||||
p.A.Genotype.RawGenotype,
|
||||
p.A.Genotype.UnmappedTokens,
|
||||
// GEN-3b: Sls (2nd spotting locus) preserved here until Kevin's GEN-3a
|
||||
// parser adopts it into the compact Genotype contract; tags + deaf too.
|
||||
Sls = p.A.Genotype.Mapped8locus.TryGetValue("Sls", out var sls) ? sls : null,
|
||||
p.A.Tags,
|
||||
p.A.Deaf,
|
||||
p.A.Zucht,
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// photos
|
||||
foreach (var rel in a.Photos)
|
||||
foreach (var rel in p.A.Photos)
|
||||
{
|
||||
var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(src)) { photosMissing++; continue; }
|
||||
@@ -202,7 +378,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
_db.GerbilPhotos.Add(new GerbilPhoto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = gid,
|
||||
GerbilId = p.Gid,
|
||||
FileName = fileName,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
@@ -212,7 +388,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
if (execute) await _db.SaveChangesAsync();
|
||||
|
||||
// ---- back-link litter parents by name (best effort) ----
|
||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||
if (execute)
|
||||
{
|
||||
foreach (var sl in litters)
|
||||
@@ -220,26 +396,95 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue;
|
||||
var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid);
|
||||
if (litter is null) continue;
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId))
|
||||
// guard: only link parents that are actually persisted (avoid an orphan FK).
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId) && persisted.Contains(fId))
|
||||
litter.FatherId = fId;
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId))
|
||||
if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId) && persisted.Contains(mId))
|
||||
litter.MotherId = mId;
|
||||
}
|
||||
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.");
|
||||
if (parentLinksAdded > 0)
|
||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||
notes.Add($"FK-Integrität: {litterParentFksDropped} Eltern-Verknüpfung(en) verworfen (Elternteil nicht ladbar), {derivedLittersSkipped} abgeleitete Würfe übersprungen (kein ladbares Elternteil). Bei 0/0 ist /import/execute FK-sicher.");
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||
|
||||
return new ImportReport(
|
||||
Executed: execute,
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting),
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs)),
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded, conflictsResolvedByDecision),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes);
|
||||
Notes: notes,
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
@@ -257,7 +502,16 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2)
|
||||
return StripCaret(pair[0]) + StripCaret(pair[1]);
|
||||
return "??";
|
||||
});
|
||||
}).ToList();
|
||||
|
||||
// GEN-3a contract (Kevin): Sls is appended LAST and ONLY for carriers — the
|
||||
// wild-type sl/sl is omitted so existing 8-locus strings stay unchanged. WP het
|
||||
// renders as the trailing token "Slsl"; S(l)S(l) is lethal so never appears.
|
||||
if (g.Mapped8locus.TryGetValue("Sls", out var sls) && sls.Count == 2
|
||||
&& !(sls[0] == "sl" && sls[1] == "sl"))
|
||||
{
|
||||
tokens.Add(StripCaret(sls[0]) + StripCaret(sls[1]));
|
||||
}
|
||||
return string.Join(' ', tokens);
|
||||
}
|
||||
|
||||
@@ -265,6 +519,11 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||
{
|
||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||
// over sire/dam name inference (PEDIGREE-LINK, Julian 2026-06-06).
|
||||
if (string.Equals(a.Gender, "male", StringComparison.OrdinalIgnoreCase)) return Gender.male;
|
||||
if (string.Equals(a.Gender, "female", StringComparison.OrdinalIgnoreCase)) return Gender.female;
|
||||
|
||||
var n = Normalize(StripZucht(a.Name));
|
||||
bool isSire = sires.Contains(n), isDam = dams.Contains(n);
|
||||
if (isSire && !isDam) return Gender.male;
|
||||
@@ -298,5 +557,26 @@ namespace GerbilManagerWebAPI.Import
|
||||
n = Regex.Replace(n, @"[^a-z0-9äöüß]", "");
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Dedup identity for parent resolution: normalized call-name + DOB.</summary>
|
||||
private static string NameDobKey(string name, DateOnly? dob) =>
|
||||
$"{Normalize(StripZucht(name))}|{dob:yyyy-MM-dd}";
|
||||
|
||||
/// <summary>Resolve a chart-position parentRef (by role) to a known gerbil id, or null.</summary>
|
||||
private static Guid? ResolveParentGid(SourceAnimal a, string role, Dictionary<string, Guid> gidByNameDob)
|
||||
{
|
||||
var pr = a.ParentRefs.FirstOrDefault(p =>
|
||||
string.Equals(p.RoleGuess, role, StringComparison.OrdinalIgnoreCase));
|
||||
if (pr is null || string.IsNullOrWhiteSpace(pr.Name)) return null;
|
||||
return gidByNameDob.TryGetValue(NameDobKey(pr.Name, ParseDate(pr.Dob)), out var id) ? id : null;
|
||||
}
|
||||
|
||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||
private sealed record AnimalPlan(
|
||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
||||
|
||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||
}
|
||||
}
|
||||
|
||||
108
GerbilManagerWebAPI/Inbox/DraftReplyService.cs
Normal file
108
GerbilManagerWebAPI/Inbox/DraftReplyService.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Inbox
|
||||
{
|
||||
/// <summary>
|
||||
/// INBOX-2: AI reply DRAFT for an incoming inquiry (human-in-the-loop — the
|
||||
/// breeder edits and sends herself, NEVER auto-send; send is INBOX-3).
|
||||
///
|
||||
/// PRIVACY / data minimization (INBOX-architecture.md §privacy): the prompt
|
||||
/// carries ONLY the inquiry's own text (signatures/quoted history stripped),
|
||||
/// the sender's display name for the greeting, a short German context, and
|
||||
/// optionally the current ForSale animals (name + Farbschlag). NOT the
|
||||
/// contact DB, NOT other requests, NOT any addresses.
|
||||
/// </summary>
|
||||
public sealed class DraftReplyService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
public sealed record ForSaleAnimal(string Name, string? Farbschlag);
|
||||
|
||||
public Task<AiCallResult> DraftAsync(
|
||||
Request request, IReadOnlyList<ForSaleAnimal> forSale, CancellationToken ct = default)
|
||||
=> _client.CompleteAsync(BuildSystemPrompt(), BuildUserPrompt(request, forSale), ct);
|
||||
|
||||
internal static string BuildSystemPrompt() => """
|
||||
Du hilfst einer Hobby-Rennmaus-Züchterin („Zucht der kleinen Chaoten“), eine
|
||||
freundliche deutsche Antwort auf eine Anfrage zu ENTWERFEN. Sie liest den
|
||||
Entwurf, passt ihn an und versendet selbst.
|
||||
|
||||
Regeln:
|
||||
- Ton: warm, persönlich, hilfsbereit — wie eine erfahrene Hobby-Züchterin,
|
||||
nicht wie ein Unternehmen. Anrede per „du“, Gruß mit dem Namen der
|
||||
anfragenden Person, falls bekannt.
|
||||
- KEINE Fakten erfinden: Verfügbarkeit, Tiere und Eigenschaften NUR aus den
|
||||
mitgelieferten Daten. Ist keine Abgabetier-Liste mitgeliefert oder passt
|
||||
nichts, verweise freundlich darauf, dass sie aktuelle Infos persönlich gibt.
|
||||
- Keine Preise/Schutzgebühren nennen und keine Adressen oder sonstige
|
||||
persönliche Daten — solche Details klärt sie selbst im Gespräch.
|
||||
- Gib NUR den Antworttext aus (ohne Betreff, ohne Erklärungen) und beende
|
||||
mit einem herzlichen Gruß ohne Namens-Signatur.
|
||||
""";
|
||||
|
||||
internal static string BuildUserPrompt(Request request, IReadOnlyList<ForSaleAnimal> forSale)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Entwirf eine Antwort auf folgende Anfrage:");
|
||||
sb.AppendLine();
|
||||
if (!string.IsNullOrWhiteSpace(request.FromName))
|
||||
sb.AppendLine($"Von: {request.FromName}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Subject))
|
||||
sb.AppendLine($"Betreff: {request.Subject}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Anfrage-Text:");
|
||||
sb.AppendLine(StripQuotedText(request.BodyText ?? ""));
|
||||
if (forSale.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Aktuell abzugebende Tiere (einzige zulässige Quelle für Verfügbarkeits-Aussagen):");
|
||||
foreach (var a in forSale)
|
||||
{
|
||||
sb.AppendLine(string.IsNullOrWhiteSpace(a.Farbschlag)
|
||||
? $"- {a.Name}"
|
||||
: $"- {a.Name} ({a.Farbschlag})");
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Signatur-/Zitat-Stripping (best effort, bewusst konservativ) ────
|
||||
|
||||
private static readonly Regex QuoteIntro = new(
|
||||
@"^Am .+ schrieb .+:\s*$", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex OutlookHeader = new(
|
||||
@"^(Von|Gesendet|An|Betreff):\s", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Drops quoted history ('>'-lines, the German Gmail "Am … schrieb …:"
|
||||
/// intro, Outlook-style forwarded headers) and everything below a signature
|
||||
/// delimiter ("-- "). Keeps the sender's own words untouched.
|
||||
/// </summary>
|
||||
internal static string StripQuotedText(string body)
|
||||
{
|
||||
var lines = body.Replace("\r\n", "\n").Split('\n');
|
||||
var kept = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.TrimEnd();
|
||||
if (trimmed == "--" || trimmed == "-- ")
|
||||
break; // signature delimiter: everything below is signature
|
||||
if (QuoteIntro.IsMatch(trimmed))
|
||||
break; // quoted history follows
|
||||
if (trimmed.StartsWith('>'))
|
||||
continue;
|
||||
if (OutlookHeader.IsMatch(trimmed))
|
||||
continue;
|
||||
kept.Add(line);
|
||||
}
|
||||
// collapse the whitespace the stripping may have left behind
|
||||
return string.Join("\n", kept).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
1381
GerbilManagerWebAPI/Migrations/20260606082045_AddGerbilDeafFlag.Designer.cs
generated
Normal file
1381
GerbilManagerWebAPI/Migrations/20260606082045_AddGerbilDeafFlag.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilDeafFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsDeaf",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsDeaf",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606085655_AddGerbilResidency.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilResidency : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsResident",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsResident",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1386
GerbilManagerWebAPI/Migrations/20260606090655_SyncColorVarietySeed.Designer.cs
generated
Normal file
1386
GerbilManagerWebAPI/Migrations/20260606090655_SyncColorVarietySeed.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,971 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SyncColorVarietySeed : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "Name",
|
||||
value: "Siam (Marder-Hell)");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "Name",
|
||||
value: "Marder dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "Name",
|
||||
value: "Silberagouti-Hell dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "Name",
|
||||
value: "Agouti-Hell dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schwarzschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Rotaugenschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG PP spsp rere", "Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG PP spsp rere", "Schwarz" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg PP spsp rere", "Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg PP spsp rere", "Anthrazit" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG PP spsp rere", "Blau" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "dd Gold" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE GG pp spsp rere", "dd Platin" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE gg pp spsp rere", "Altweiss (REW)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg pp spsp rere", "Apricot (Blassfuchs)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg pp spsp rere", "C-Separator" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE gg pp spsp rere", "Elfenbein" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "Name",
|
||||
value: "Marder");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Schimmel (Orangeschimmel)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
column: "Name",
|
||||
value: "Siam (Marder-Hell) dd");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
column: "Name",
|
||||
value: "Silberagouti dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
column: "Name",
|
||||
value: "Agouti dd CP");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg PP spsp rere", "Blaufuchs dd" });
|
||||
}
|
||||
}
|
||||
}
|
||||
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
1302
GerbilManagerWebAPI/Migrations/20260606095755_ReseedColorVarietiesGen3f.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,632 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReseedColorVarietiesGen3f : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(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"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000071"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000072"));
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000073"));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG pp spsp rere", "Topas dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee gg pp spsp rere", "Blaufuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmch DD EE GG PP spsp rere", "Siam" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmch DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "CP-Agouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE gg PP spsp rere", "CP-Silberagouti" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee GG PP spsp rere", "CP-Algierfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "CP-Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd ee GG PP spsp rere", "CP-Fuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmch dd ee GG PP spsp rere", "CP-Fuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd ee gg PP spsp rere", "CP-Blaufuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Marder" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE GG PP spsp rere", "Siam (Marder-Hell)" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Saphir" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Orangeschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Topas" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD EE GG pp spsp rere", "Platin-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE GG PP spsp rere", "Agouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC dd EE gg PP spsp rere", "Silberagouti dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd ee GG PP spsp rere", "Kohlfuchs dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC dd EE gg PP spsp rere", "Anthrazit dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee gg PP spsp rere", "Blaufuchs CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Silberschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef gg PP spsp rere", "Polarfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG PP spsp rere", "Algierfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs-Hell CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef gg PP spsp rere", "Blaufuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee GG pp spsp rere", "Goldfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD efef GG pp spsp rere", "Goldfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD EE GG pp spsp rere", "Gold-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Siam (Marder-Hell) dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE GG PP spsp rere", "Marder dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD EE gg PP spsp rere", "Zobel-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE gg PP spsp rere", "Silberagouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA cchmcchm dd EE GG PP spsp rere", "Agouti-Hell dd CP" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee gg PP spsp rere", "Blaufuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG pp spsp rere", "Rotfuchsschimmel" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "AA CC DD ee gg PP spsp rere", "Polarfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD efef GG PP spsp rere", "Kohlfuchsschimmel, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG pp spsp rere", "Rotfuchs, hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm dd EE gg PP spsp rere", "Zobel dd" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa CC DD ee GG PP spsp rere", "Kohlfuchs-Hell" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
columns: new[] { "CanonicalGenotype", "Name" },
|
||||
values: new object[] { "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs CP" });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "ColorVarieties",
|
||||
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs CP", 61 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmcchm DD EE gg PP spsp rere", "Silberagouti CP", 62 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmcchm DD EE GG PP spsp rere", "Agouti CP", 63 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmcchm DD ee GG PP spsp rere", "Algierfuchs-Hell CP", 64 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000066"), "aa cchmcchm DD ee GG PP spsp rere", "Kohlfuchs,hell CP", 65 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA cchmcchm DD ee gg PP spsp rere", "Polarfuchs CP", 66 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC DD ee GG PP spsp rere", "Algierfuchs, hell", 67 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000069"), "AA CC dd EE GG pp spsp rere", "Topas dd", 68 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000070"), "aa cchmcchm dd EE gg PP spsp rere", "Zobel-Hell dd", 69 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000071"), "aa cchmcchm DD efef GG PP spsp rere", "Kohlfuchsschimmel CP", 70 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000072"), "aa CC dd ee gg pp spsp rere", "Blaufuchs dd", 71 },
|
||||
{ new Guid("00000000-0000-0000-0000-000000000073"), "AA cchmcchm DD efef GG PP spsp rere", "CP-Orangeschimmel", 72 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,485 +210,401 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000005"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Schwarzschimmel",
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Rotaugenschimmel",
|
||||
SortOrder = 4
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Rotaugenschimmel",
|
||||
CanonicalGenotype = "AA CC DD EE GG PP spsp rere",
|
||||
Name = "Agouti",
|
||||
SortOrder = 5
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000007"),
|
||||
CanonicalGenotype = "AA CC DD EE GG PP spsp rere",
|
||||
Name = "Agouti",
|
||||
CanonicalGenotype = "aa CC DD EE GG PP spsp rere",
|
||||
Name = "Schwarz",
|
||||
SortOrder = 6
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000008"),
|
||||
CanonicalGenotype = "aa CC DD EE GG PP spsp rere",
|
||||
Name = "Schwarz",
|
||||
CanonicalGenotype = "AA CC DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti",
|
||||
SortOrder = 7
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000009"),
|
||||
CanonicalGenotype = "AA CC DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti",
|
||||
CanonicalGenotype = "aa CC DD EE gg PP spsp rere",
|
||||
Name = "Anthrazit",
|
||||
SortOrder = 8
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000010"),
|
||||
CanonicalGenotype = "aa CC DD EE gg PP spsp rere",
|
||||
Name = "Anthrazit",
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs",
|
||||
SortOrder = 9
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000011"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs",
|
||||
CanonicalGenotype = "aa CC dd EE GG PP spsp rere",
|
||||
Name = "Blau",
|
||||
SortOrder = 10
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000012"),
|
||||
CanonicalGenotype = "aa CC dd EE GG PP spsp rere",
|
||||
Name = "Blau",
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold",
|
||||
SortOrder = 11
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000013"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold",
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin",
|
||||
SortOrder = 12
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000014"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin",
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs",
|
||||
SortOrder = 13
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000015"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs",
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs",
|
||||
SortOrder = 14
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs",
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "dd Gold",
|
||||
SortOrder = 15
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "dd Gold",
|
||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||
Name = "dd Platin",
|
||||
SortOrder = 16
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000018"),
|
||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||
Name = "dd Platin",
|
||||
CanonicalGenotype = "aa CC DD EE gg pp spsp rere",
|
||||
Name = "Altweiss (REW)",
|
||||
SortOrder = 17
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000019"),
|
||||
CanonicalGenotype = "aa CC DD EE gg pp spsp rere",
|
||||
Name = "Altweiss (REW)",
|
||||
CanonicalGenotype = "AA CC DD ee gg pp spsp rere",
|
||||
Name = "Apricot (Blassfuchs)",
|
||||
SortOrder = 18
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000020"),
|
||||
CanonicalGenotype = "AA CC DD ee gg pp spsp rere",
|
||||
Name = "Apricot (Blassfuchs)",
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs",
|
||||
SortOrder = 19
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000021"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs",
|
||||
CanonicalGenotype = "aa CC DD ee gg pp spsp rere",
|
||||
Name = "C-Separator",
|
||||
SortOrder = 20
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000022"),
|
||||
CanonicalGenotype = "aa CC DD ee gg pp spsp rere",
|
||||
Name = "C-Separator",
|
||||
CanonicalGenotype = "AA CC DD EE gg pp spsp rere",
|
||||
Name = "Elfenbein",
|
||||
SortOrder = 21
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000023"),
|
||||
CanonicalGenotype = "AA CC DD EE gg pp spsp rere",
|
||||
Name = "Elfenbein",
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs",
|
||||
SortOrder = 22
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs",
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs",
|
||||
SortOrder = 23
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Marder",
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Saphir",
|
||||
SortOrder = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell)",
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Orangeschimmel",
|
||||
SortOrder = 25
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000027"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs",
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Topas",
|
||||
SortOrder = 26
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Saphir",
|
||||
Name = "Platin-Hell",
|
||||
SortOrder = 27
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Schimmel (Orangeschimmel)",
|
||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd",
|
||||
SortOrder = 28
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Topas",
|
||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd",
|
||||
SortOrder = 29
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin-Hell",
|
||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs dd",
|
||||
SortOrder = 30
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd",
|
||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||
Name = "Anthrazit dd",
|
||||
SortOrder = 31
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd",
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Silberschimmel",
|
||||
SortOrder = 32
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs dd",
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 33
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||
Name = "Anthrazit dd",
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 34
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP-Hell",
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 35
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs CP",
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 36
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs, hell",
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Silberschimmel",
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs, hell",
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs-Hell CP",
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold-Hell",
|
||||
SortOrder = 40
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs, hell",
|
||||
SortOrder = 41
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 42
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs, hell",
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs, hell",
|
||||
SortOrder = 43
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs, hell",
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 44
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs, hell",
|
||||
SortOrder = 45
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold-Hell",
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs-Hell",
|
||||
SortOrder = 46
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell) dd",
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs, hell",
|
||||
SortOrder = 47
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Marder dd",
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "Topas dd",
|
||||
SortOrder = 48
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell",
|
||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||
Name = "Blaufuchs dd",
|
||||
SortOrder = 49
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd CP",
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Marder",
|
||||
SortOrder = 50
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti-Hell dd CP",
|
||||
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
||||
Name = "Siam",
|
||||
SortOrder = 51
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd CP",
|
||||
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell",
|
||||
SortOrder = 52
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Agouti-Hell dd CP",
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "CP-Agouti",
|
||||
SortOrder = 53
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs, hell",
|
||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "CP-Silberagouti",
|
||||
SortOrder = 54
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "CP-Algierfuchs",
|
||||
SortOrder = 55
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs, hell",
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "CP-Polarfuchs",
|
||||
SortOrder = 56
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
||||
Name = "CP-Fuchs",
|
||||
SortOrder = 57
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs, hell",
|
||||
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
||||
Name = "CP-Fuchs-Hell",
|
||||
SortOrder = 58
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel dd",
|
||||
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
||||
Name = "CP-Blaufuchs",
|
||||
SortOrder = 59
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs-Hell",
|
||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "CP-Orangeschimmel",
|
||||
SortOrder = 60
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs CP",
|
||||
SortOrder = 61
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs CP",
|
||||
SortOrder = 62
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti CP",
|
||||
SortOrder = 63
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP",
|
||||
SortOrder = 64
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs-Hell CP",
|
||||
SortOrder = 65
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs,hell CP",
|
||||
SortOrder = 66
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs CP",
|
||||
SortOrder = 67
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs, hell",
|
||||
SortOrder = 68
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "Topas dd",
|
||||
SortOrder = 69
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell dd",
|
||||
SortOrder = 70
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel CP",
|
||||
SortOrder = 71
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
|
||||
Name = "Blaufuchs dd",
|
||||
SortOrder = 72
|
||||
});
|
||||
});
|
||||
|
||||
@@ -781,6 +697,14 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("ImportSource")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool?>("IsDeaf")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsResident")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
@@ -67,6 +67,19 @@ namespace GerbilManagerWebAPI.Models
|
||||
|
||||
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
|
||||
public string? CharacterNote { get; set; }
|
||||
|
||||
/// <summary>GEN-3b: hearing/deaf phenotype flag (NOT a genotype locus — it's the
|
||||
/// downstream effect of high white load / Sp×Sls). null = not stated, true = deaf
|
||||
/// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the
|
||||
/// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary>
|
||||
public bool? IsDeaf { get; set; }
|
||||
|
||||
/// <summary>Residency/ownership (ORIGIN, distinct from Abgabe location): true = part of
|
||||
/// the Clan-kleine-Chaoten Bestand, false = external pedigree ancestor (bred elsewhere).
|
||||
/// Rule (a) Zuchtname matches the Clan kennel, OR (b) it's a parent of a Clan offspring.
|
||||
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
|
||||
@@ -41,6 +41,9 @@ builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
|
||||
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
builder.Services.AddDataProtection();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
|
||||
public static class SaleAdPromptBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Two few-shot examples derived from the documented listing template.
|
||||
/// NOTE: the research file documents the format and one real tagline;
|
||||
/// these examples are synthesized to that template. Swap in verbatim
|
||||
/// listings from kleine-chaoten.jimdofree.com when available.
|
||||
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
|
||||
/// These are real listings in the breeder's own voice — the strongest grounding source.
|
||||
/// </summary>
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
Benny – Schwarz Schecke, geboren am 12.03.2024
|
||||
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.
|
||||
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.
|
||||
|
||||
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 = """
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
Fine – Agouti, geboren am 28.06.2024
|
||||
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
|
||||
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.
|
||||
|
||||
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>
|
||||
@@ -58,7 +52,8 @@ namespace GerbilManagerWebAPI.SaleAd
|
||||
|
||||
Harte Regeln:
|
||||
- NIEMALS Preise oder Schutzgebühren nennen.
|
||||
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Notizen). Fehlt eine Angabe, lässt du sie weg.
|
||||
- KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Charakter-Angaben, Notizen). Fehlt eine Angabe, lässt du sie weg.
|
||||
- Charakter-Eigenschaften (Stichworte wie „zutraulich“, „buddelt gern“) und die Charakter-Notiz sind die Grundlage der Persönlichkeits-Prosa: Verwebe sie zu FLIESSENDEM Text — niemals als Aufzählung oder Stichwortliste ausgeben.
|
||||
- Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben.
|
||||
- Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke.
|
||||
|
||||
@@ -85,12 +80,18 @@ namespace GerbilManagerWebAPI.SaleAd
|
||||
sb.Append($" | Farbschlag: {animal.Farbschlag}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.DateOfBirth))
|
||||
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.Notes))
|
||||
sb.Append($" | Notizen: {animal.Notes}");
|
||||
// FEAT-14c: Charakterbogen (Traits + Notiz) ist die BEVORZUGTE
|
||||
// Charakterquelle; die generischen Notizen dienen nur als
|
||||
// Fallback, wenn kein Charakterbogen gepflegt ist (sie enthalten
|
||||
// oft Verwaltungs-Infos, die nicht ins Inserat gehören).
|
||||
var hasCharacter = animal.Traits is { Count: > 0 }
|
||||
|| !string.IsNullOrWhiteSpace(animal.CharacterNote);
|
||||
if (animal.Traits is { Count: > 0 })
|
||||
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
|
||||
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
|
||||
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
|
||||
if (!hasCharacter && !string.IsNullOrWhiteSpace(animal.Notes))
|
||||
sb.Append($" | Notizen: {animal.Notes}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Hints))
|
||||
|
||||
@@ -1,87 +1,35 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.SaleAd
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-12a: provider-agnostic AI client for sale-ad generation.
|
||||
///
|
||||
/// Speaks the OpenAI-compatible chat-completions wire shape — deliberately
|
||||
/// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions
|
||||
/// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
|
||||
/// local Ollama and any future provider. The wire shape IS the abstraction.
|
||||
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
|
||||
/// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
|
||||
/// future AI features reuse the SAME implementation); this service contributes
|
||||
/// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
|
||||
/// </summary>
|
||||
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var ai = options.Value;
|
||||
if (!ai.IsConfigured)
|
||||
var result = await _client.CompleteAsync(
|
||||
SaleAdPromptBuilder.BuildSystemPrompt(),
|
||||
SaleAdPromptBuilder.BuildUserPrompt(request),
|
||||
ct);
|
||||
var status = result.Status switch
|
||||
{
|
||||
return new SaleAdResult(SaleAdStatus.NotConfigured, null,
|
||||
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model).");
|
||||
}
|
||||
|
||||
var payload = new ChatRequest(
|
||||
Model: ai.Model!,
|
||||
Messages:
|
||||
[
|
||||
new ChatMessage("system", SaleAdPromptBuilder.BuildSystemPrompt()),
|
||||
new ChatMessage("user", SaleAdPromptBuilder.BuildUserPrompt(request)),
|
||||
],
|
||||
Temperature: 0.7);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!))
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8, "application/json"),
|
||||
AiCallStatus.Ok => SaleAdStatus.Ok,
|
||||
AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
|
||||
_ => SaleAdStatus.UpstreamError,
|
||||
};
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await http.SendAsync(httpRequest, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
||||
$"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
var completion = JsonSerializer.Deserialize<ChatResponse>(body, JsonOptions);
|
||||
var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
return string.IsNullOrWhiteSpace(text)
|
||||
? new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
||||
"KI-Antwort enthielt keinen Text.")
|
||||
: new SaleAdResult(SaleAdStatus.Ok, text);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new SaleAdResult(SaleAdStatus.UpstreamError, null,
|
||||
$"KI-Anbieter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
return new SaleAdResult(status, result.Text, result.Error);
|
||||
}
|
||||
|
||||
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
|
||||
/// <summary>Forwarder kept for the existing config-matrix tests.</summary>
|
||||
internal static Uri BuildCompletionsUri(string baseUrl) =>
|
||||
new($"{baseUrl.TrimEnd('/')}/chat/completions");
|
||||
|
||||
// ── OpenAI-compatible wire records (request + the slice of the response we read) ──
|
||||
internal sealed record ChatRequest(string Model, List<ChatMessage> Messages, double? Temperature);
|
||||
|
||||
internal sealed record ChatMessage(string Role, string Content);
|
||||
|
||||
internal sealed record ChatResponse(List<ChatChoice>? Choices);
|
||||
|
||||
internal sealed record ChatChoice(ChatMessage? Message);
|
||||
OpenAiChatClient.BuildCompletionsUri(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +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 |
|
||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
||||
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1–D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
|
||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
||||
|
||||
### 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.)*
|
||||
@@ -59,42 +61,78 @@ 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)
|
||||
|
||||
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
|
||||
|
||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
||||
|
||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
||||
| Tier | im Farbschlag steht fälschlich |
|
||||
|---|---|
|
||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" |
|
||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) |
|
||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) |
|
||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" |
|
||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) |
|
||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) |
|
||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) |
|
||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" |
|
||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) |
|
||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) |
|
||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" |
|
||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
||||
|
||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
||||
|---|---|---|
|
||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
||||
|
||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
||||
|
||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
||||
Firefly v.d. K.C. (*18.12.2019, DD↔D-) · Zuleika v.d. K.C. (*24.10.2015) · WildFire v.d. K.C. (*05.10.2017, PP↔P-) · Milka of LennyLengo (*09.12.2018) · Silvain v.d. K.C. (*27.03.2022) · Daja of Little Rose (*16.05.2021, spsp) · Ichika v.d. K.C. (*19.04.2020) · Chelsea v.d. K.C. (*02.04.2021)
|
||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
||||
|
||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||
|---|---|---|
|
||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
||||
|
||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
||||
Vestra von den Schlossmäusen (*08.02.2019, WP) · Hedwig of BGB (*30.10.2019, DP + hörend) · Pitari gen. Piti v.d. K.C. (*16.05.2021, DP) · Little Hero of Black Forest (*22.02.2018, WFNZ ± spsp) · Victoria Welby gen. Welby v.d. K.C. (*16.01.2023, DP + Genotyp Ee[f]↔ee[f])
|
||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
||||
>
|
||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
||||
|
||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
||||
|---|---|---|
|
||||
| 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]** — 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 |
|
||||
| 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) |
|
||||
|
||||
### D5 · **Sterbedatum** widersprüchlich
|
||||
| Tier | Problem |
|
||||
|---|---|
|
||||
| Flint v.d. Kleinen Chaoten (*23.12.2017) | Tod 10.05.**2021** vs. 10.05.**2022** (Jahr-Tippfehler?) |
|
||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
||||
| Molly of Black Forest (*13.09.2021) | Tod **03.05.2021 — VOR der Geburt** → klarer Datenfehler, bitte korrigieren |
|
||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
||||
|
||||
*(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)
|
||||
@@ -121,7 +159,7 @@ sagen, was ergänzt oder gestrichen werden soll:**
|
||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
||||
| Restliche importierte Tiere (Konflikte/Sonder-Kürzel) | in Quarantäne | **C2–C6** (C1 ✅ erledigt → D2-Gruppe + Uw/Marker-Tiere lädt Michael nach) |
|
||||
| Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
|
||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
gerbilmanagerwebapi:
|
||||
image: gerbilmanagerwebapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
args:
|
||||
- configuration=Debug
|
||||
ports:
|
||||
- 80:80
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
volumes:
|
||||
- ~/.vsdbg:/remote_debugger:rw
|
||||
@@ -1,57 +0,0 @@
|
||||
# Please refer https://aka.ms/HTTPSinContainer on how to setup an https developer certificate for your ASP.NET Core service.
|
||||
|
||||
version: '3.4'
|
||||
|
||||
services:
|
||||
|
||||
frontend:
|
||||
image: gerbilmanagerweb
|
||||
build:
|
||||
context: .
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
args:
|
||||
# Der Browser erreicht die API über den am Host veröffentlichten Port
|
||||
- VITE_API_BASE_URL=http://localhost:80
|
||||
ports:
|
||||
- 3000:3000
|
||||
networks:
|
||||
- net2
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
image: gerbilmanagerwebapi
|
||||
build:
|
||||
context: .
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
environment:
|
||||
- ConnectionStrings:sqlConnection=server=database; database=GerbilManager; User Id=sa;Password=StrongerThenYYou1;Encrypt=False;TrustServerCertificate=True
|
||||
ports:
|
||||
- 80:80
|
||||
networks:
|
||||
- net1
|
||||
- net2
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
database:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- MSSQL_SA_PASSWORD=StrongerThenYYou1
|
||||
- MSSQL_PID=Evaluation
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- db-data:/var/opt/mssql
|
||||
networks:
|
||||
- net1
|
||||
|
||||
volumes:
|
||||
db-data:
|
||||
|
||||
networks:
|
||||
net1:
|
||||
name: network1
|
||||
net2:
|
||||
name: network2
|
||||
3
gerbil-manager-web/.gitattributes
vendored
Normal file
3
gerbil-manager-web/.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# de.ts is large and edited by many agents — pin to LF so EOL flips don't
|
||||
# create noisy whole-file diffs (GEN-3 follow-up, god-approved).
|
||||
src/strings/de.ts text eol=lf
|
||||
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()
|
||||
})
|
||||
})
|
||||
89
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
89
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang — Liste/Filter, Triage, KI-Entwurf (inkl.
|
||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||
*/
|
||||
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const ta = de.pages.anfragen
|
||||
const td = ta.detail
|
||||
|
||||
test.describe('Anfragen', () => {
|
||||
test('Liste zeigt Anfragen neueste zuerst + Status-Filter', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.requests)
|
||||
await expect(page.getByRole('heading', { name: ta.title })).toBeVisible()
|
||||
|
||||
// Neueste zuerst: Anna (05.06.) vor Ben (04.06.) vor Clara (01.06.)
|
||||
const cards = page.locator('.anfrage-card')
|
||||
await expect(cards).toHaveCount(3)
|
||||
await expect(cards.nth(0)).toContainText('Anna Albrecht')
|
||||
await expect(cards.nth(2)).toContainText('clara@example.com') // ohne Anzeigename → Adresse
|
||||
|
||||
// Status-Badge auf der Karte
|
||||
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
|
||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||
await expect(cards).toHaveCount(1)
|
||||
await expect(cards.first()).toContainText('Danke!')
|
||||
})
|
||||
|
||||
test('Sync ohne Gmail-Konfiguration zeigt deutschen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.mailConfigured = false
|
||||
await page.goto('/anfragen')
|
||||
await page.getByRole('button', { name: ta.sync }).click()
|
||||
await expect(page.getByText(ta.mailNotConfigured)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detail: Triage — Abnehmer zuordnen setzt Status auf Zugeordnet', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await expect(page.getByRole('heading', { name: 'Anfrage: Pärchen zur Abgabe?' })).toBeVisible()
|
||||
await expect(page.getByText('anna@example.de', { exact: false })).toBeVisible()
|
||||
|
||||
await page.getByLabel(td.assignContact).selectOption({ label: 'Zoohandlung Meier' })
|
||||
await expect(page.locator('.anfrage-badge--assigned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
|
||||
// Verwerfen (mit Bestätigung) → Status Verworfen, Zuordnung bleibt
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.abandon }).click()
|
||||
await expect(page.locator('.anfrage-badge--abandoned')).toBeVisible()
|
||||
expect(mockDb!.requests.find((r) => r.id === 'req-anna')!.assignedContactId).toBe('con-meier')
|
||||
})
|
||||
|
||||
test('KI-Entwurf füllt das Antwortfeld; Senden markiert als Beantwortet', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
const textarea = page.locator('.anfrage-reply__text')
|
||||
await expect(textarea).toHaveValue(/vielen Dank für deine Anfrage/)
|
||||
|
||||
// Entwurf bearbeiten, dann senden (bestätigt) → Beantwortet + Hinweis
|
||||
await textarea.fill('Hallo Anna, ja — die beiden suchen noch ein Zuhause!')
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sent)).toBeVisible()
|
||||
await expect(page.locator('.anfrage-badge--answered')).toBeVisible()
|
||||
})
|
||||
|
||||
test('KI ohne Schlüssel: 503 wird zum freundlichen Hinweis', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
mockDb!.aiConfigured = false
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.draftButton }).click()
|
||||
await expect(page.getByText(td.aiKeyMissing)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Senden ohne Text zeigt Hinweis statt Versand', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/anfragen/req-anna')
|
||||
await page.getByRole('button', { name: td.send }).click()
|
||||
await expect(page.getByText(td.sendEmptyBody)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
|
||||
|
||||
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
||||
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 expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
|
||||
const name = uniqueName('Kontakt')
|
||||
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
||||
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
||||
|
||||
45
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
45
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
|
||||
test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
// Bestand ist sichtbar …
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
// … aber die externe Ahne „Max“ (isResident=false) nicht.
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
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()
|
||||
|
||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||
await expect(maxRow).toBeVisible()
|
||||
await expect(maxRow.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||
// Der Bestand bleibt weiterhin sichtbar.
|
||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bearbeiten-Formular kann ein Tier als extern markieren (Bestand-Häkchen)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel gehört zum Bestand -> Häkchen entfernen und speichern.
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
const check = page.getByRole('checkbox', { name: t.form.isResidentLabel })
|
||||
await expect(check).toBeChecked()
|
||||
await check.uncheck()
|
||||
await page.getByRole('button', { name: t.form.save }).click()
|
||||
|
||||
// Auf der Detailseite ist Krümel jetzt als „Extern“ markiert.
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByText(t.externalBadge, { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -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.
|
||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||
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,6 +39,17 @@ export function skipUnlessMock() {
|
||||
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,
|
||||
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||
@@ -63,6 +74,18 @@ export function acceptNextDialog(page: Page) {
|
||||
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. */
|
||||
export const uniqueName = (prefix: string) =>
|
||||
`${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()
|
||||
})
|
||||
57
gerbil-manager-web/e2e/live.spec.ts
Normal file
57
gerbil-manager-web/e2e/live.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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()
|
||||
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
|
||||
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
|
||||
await gotoSection(page, de.nav.litters)
|
||||
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
|
||||
|
||||
// Mindestens 1 Wurf in der Liste; ersten öffnen.
|
||||
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
|
||||
await expect(firstLink).toBeVisible()
|
||||
await firstLink.click()
|
||||
|
||||
// Detailseite: Elterntier-Abschnitt ist sichtbar
|
||||
await expect(page.getByText(tl.detail.parents)).toBeVisible()
|
||||
// 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'),
|
||||
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
||||
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
||||
contracts: collection(db.contracts as unknown as Row[], 'contract'),
|
||||
}
|
||||
|
||||
const handler = async (route: Route) => {
|
||||
@@ -135,6 +136,95 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
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) ──
|
||||
if (path === '/requests/sync' && method === 'POST') {
|
||||
return json(route, 200, db.mailConfigured ? { imported: 0, error: null } : { imported: 0, error: 'MailNotConfigured' })
|
||||
}
|
||||
let rm = path.match(/^\/requests\/([^/]+)\/draft$/)
|
||||
if (rm && method === 'POST') {
|
||||
if (!db.aiConfigured) {
|
||||
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === rm![1])
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
r.draftReply = `Hallo ${r.fromName ?? ''},\n\nvielen Dank für deine Anfrage!\n\nViele Grüße`
|
||||
return json(route, 200, r)
|
||||
}
|
||||
rm = path.match(/^\/requests\/([^/]+)\/send$/)
|
||||
if (rm && method === 'POST') {
|
||||
if (!db.mailConfigured) {
|
||||
return json(route, 503, { title: 'MailNotConfigured', detail: 'Gmail ist noch nicht konfiguriert.' })
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === rm![1])
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
r.status = 'Answered'
|
||||
r.answeredAt = '2026-06-06T10:00:00Z'
|
||||
return json(route, 200, r)
|
||||
}
|
||||
rm = path.match(/^\/requests(?:\/([^/]+))?$/)
|
||||
if (rm) {
|
||||
const reqId = rm[1] ? decodeURIComponent(rm[1]) : null
|
||||
if (!reqId && method === 'GET') {
|
||||
return json(route, 200, pagedResponse(db.requests as unknown as Row[], url))
|
||||
}
|
||||
const r = db.requests.find((x) => x.id === reqId)
|
||||
if (!r) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'GET') return json(route, 200, r)
|
||||
if (method === 'PUT') {
|
||||
// Triage-Vertrag des Backends: assignedContactId wird IMMER übernommen.
|
||||
const body = request.postDataJSON() as { status?: string | null; assignedContactId?: string | null }
|
||||
r.assignedContactId = body.assignedContactId ?? null
|
||||
if (body.status) {
|
||||
r.status = body.status as typeof r.status
|
||||
if (body.status === 'Answered') r.answeredAt ??= '2026-06-06T10:00:00Z'
|
||||
}
|
||||
return json(route, 204)
|
||||
}
|
||||
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
|
||||
// generischen /gerbils/:id-Route abfangen.
|
||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||
@@ -168,6 +258,109 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
})
|
||||
}
|
||||
|
||||
// WEB-0b: CMS-Endpunkte (Seiten + Blöcke). Vor den generischen Kollektionen,
|
||||
// weil 'pages'/'blocks' verschachtelte Routen haben und kein Standard-CRUD sind.
|
||||
const toSummary = (p: (typeof db.pages)[number]) => ({
|
||||
id: p.id, slug: p.slug, title: p.title, seoDescription: p.seoDescription, status: p.status,
|
||||
})
|
||||
const toBlock = (b: { id: string; order: number; type: string; data: unknown }) => ({
|
||||
id: b.id, order: b.order, type: b.type, data: b.data,
|
||||
})
|
||||
const toPage = (p: (typeof db.pages)[number]) => ({
|
||||
...toSummary(p),
|
||||
blocks: [...p.blocks].sort((a, b) => a.order - b.order).map(toBlock),
|
||||
})
|
||||
|
||||
if (path === '/pages' && method === 'GET') {
|
||||
return json(route, 200, [...db.pages].sort((a, b) => a.slug.localeCompare(b.slug)).map(toSummary))
|
||||
}
|
||||
// GET /pages/{slug} · PUT /pages/{id}
|
||||
m = path.match(/^\/pages\/([^/]+)$/)
|
||||
if (m && method === 'GET') {
|
||||
const p = db.pages.find((x) => x.slug === decodeURIComponent(m![1]))
|
||||
return p ? json(route, 200, toPage(p)) : json(route, 404, { title: 'Not Found' })
|
||||
}
|
||||
// PUT /pages/{id}
|
||||
if (m && method === 'PUT') {
|
||||
const p = db.pages.find((x) => x.id === m![1])
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as Partial<typeof p>
|
||||
if (typeof body.slug === 'string') p.slug = body.slug
|
||||
if (typeof body.title === 'string') p.title = body.title
|
||||
p.seoDescription = (body.seoDescription as string | null) ?? null
|
||||
if (body.status === 'Draft' || body.status === 'Published') p.status = body.status
|
||||
return json(route, 204)
|
||||
}
|
||||
// POST /pages/{pageId}/blocks
|
||||
m = path.match(/^\/pages\/([^/]+)\/blocks$/)
|
||||
if (m && method === 'POST') {
|
||||
const p = db.pages.find((x) => x.id === m![1])
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as { type: string; data?: Record<string, unknown>; order?: number }
|
||||
const order = body.order ?? (p.blocks.reduce((mx, b) => Math.max(mx, b.order), -1) + 1)
|
||||
const block = { id: newId('blk'), order, type: body.type, data: body.data ?? {} }
|
||||
p.blocks.push(block)
|
||||
return json(route, 201, toBlock(block))
|
||||
}
|
||||
// PUT /pages/{pageId}/blocks/order (Body: { blockIds })
|
||||
m = path.match(/^\/pages\/([^/]+)\/blocks\/order$/)
|
||||
if (m && method === 'PUT') {
|
||||
const p = db.pages.find((x) => x.id === m![1])
|
||||
if (!p) return json(route, 404, { title: 'Not Found' })
|
||||
const { blockIds } = request.postDataJSON() as { blockIds: string[] }
|
||||
const byId = new Map(p.blocks.map((b) => [b.id, b]))
|
||||
if (blockIds.length !== p.blocks.length || blockIds.some((id) => !byId.has(id)))
|
||||
return json(route, 400, 'blockIds must list exactly the page block ids.')
|
||||
blockIds.forEach((id, i) => { byId.get(id)!.order = i })
|
||||
return json(route, 204)
|
||||
}
|
||||
// PUT/DELETE /blocks/{id}
|
||||
m = path.match(/^\/blocks\/([^/]+)$/)
|
||||
if (m) {
|
||||
const pageOf = db.pages.find((p) => p.blocks.some((b) => b.id === m![1]))
|
||||
const block = pageOf?.blocks.find((b) => b.id === m![1])
|
||||
if (!pageOf || !block) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'PUT') {
|
||||
const body = request.postDataJSON() as { type?: string; data?: Record<string, unknown>; order?: number }
|
||||
if (typeof body.type === 'string') block.type = body.type
|
||||
if (body.data) block.data = body.data
|
||||
if (typeof body.order === 'number') block.order = body.order
|
||||
return json(route, 204)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
pageOf.blocks = pageOf.blocks.filter((b) => b.id !== m![1])
|
||||
return json(route, 204)
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* FEAT-2/FEAT-6-Flüsse.
|
||||
*/
|
||||
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
||||
import type { InboxRequest } from '../src/api/requests'
|
||||
|
||||
export interface HealthRecordRow {
|
||||
id: string
|
||||
@@ -25,6 +26,37 @@ export interface WeightRecordRow {
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
/** WEB-0b: CMS-Block (data ist das typ-spezifische JSON-Objekt). */
|
||||
export interface MockBlock {
|
||||
id: string
|
||||
order: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** WEB-0b: CMS-Seite der öffentlichen Webseite. */
|
||||
export interface MockPage {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: 'Draft' | 'Published'
|
||||
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 {
|
||||
gerbils: Gerbil[]
|
||||
litters: Litter[]
|
||||
@@ -33,6 +65,15 @@ export interface MockDb {
|
||||
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||
healthRecords: HealthRecordRow[]
|
||||
weightRecords: WeightRecordRow[]
|
||||
pages: MockPage[]
|
||||
// INBOX-1: Anfragen-Posteingang. Die Flags steuern die Fehlerpfade des Mocks
|
||||
// (Specs können sie pro Test umlegen): KI-Entwurf 503 / Sync+Send Mail-Fehler.
|
||||
requests: InboxRequest[]
|
||||
aiConfigured: boolean
|
||||
mailConfigured: boolean
|
||||
// ABGABE: Verträge + KI-Inserat-Flag
|
||||
contracts: MockContract[]
|
||||
saleAdConfigured: boolean
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -63,6 +104,8 @@ function gerbil(
|
||||
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
||||
genotype,
|
||||
notes: null,
|
||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||
isResident: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +128,27 @@ export function seedDb(): MockDb {
|
||||
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
||||
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
||||
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
||||
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'),
|
||||
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'),
|
||||
// BESTAND-FILTER: externe Ahnen (Gründertiere fremder Zuchten, nur für den Stammbaum).
|
||||
{ ...gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||
{ ...gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'), isResident: false, originBreeder: 'Zoohandlung Meier' },
|
||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||
{ ...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('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[] = [
|
||||
@@ -142,5 +200,95 @@ export function seedDb(): MockDb {
|
||||
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
||||
]
|
||||
|
||||
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords }
|
||||
// WEB-0b: die 6 Webseiten-Seiten (wie vom Backend geseedet), mit ein paar Blöcken.
|
||||
const pages: MockPage[] = [
|
||||
{
|
||||
id: 'page-start',
|
||||
slug: 'start',
|
||||
title: 'Startseite',
|
||||
seoDescription: 'Willkommen bei unserer Rennmauszucht.',
|
||||
status: 'Published',
|
||||
blocks: [
|
||||
{ id: 'blk-start-1', order: 0, type: 'Heading', data: { text: 'Willkommen', level: 2 } },
|
||||
{ id: 'blk-start-2', order: 1, type: 'RichText', data: { markdown: 'Schön, dass du da bist.' } },
|
||||
],
|
||||
},
|
||||
{ id: 'page-zucht', slug: 'ueber-die-zucht', title: 'Über die Zucht', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{
|
||||
id: 'page-abgabe',
|
||||
slug: 'abgabetiere',
|
||||
title: 'Abgabetiere',
|
||||
seoDescription: null,
|
||||
status: 'Published',
|
||||
blocks: [
|
||||
{ id: 'blk-abgabe-1', order: 0, type: 'AbgabetiereList', data: { mode: 'auto', intro: 'Diese Tiere suchen ein Zuhause.' } },
|
||||
],
|
||||
},
|
||||
{ id: 'page-bedingungen', slug: 'abgabebedingungen', title: 'Abgabebedingungen', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{ id: 'page-farben', slug: 'farben-genetik', title: 'Farben & Genetik', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
{ id: 'page-kontakt', slug: 'kontakt', title: 'Kontakt', seoDescription: null, status: 'Draft', blocks: [] },
|
||||
]
|
||||
|
||||
// INBOX-1: Anfragen (neueste zuerst nach receivedAt sortierbar)
|
||||
const requests: InboxRequest[] = [
|
||||
{
|
||||
id: 'req-anna',
|
||||
gmailMessageId: '<anna-1@mail.example>',
|
||||
threadId: 'thr-1',
|
||||
fromAddress: 'anna@example.de',
|
||||
fromName: 'Anna Albrecht',
|
||||
subject: 'Anfrage: Pärchen zur Abgabe?',
|
||||
bodyText:
|
||||
'Hallo,\n\nich habe euer Inserat gesehen — sind die beiden Agouti-Jungs noch zu haben?\n\nViele Grüße\nAnna',
|
||||
receivedAt: '2026-06-05T18:30:00Z',
|
||||
status: 'New',
|
||||
assignedContactId: null,
|
||||
draftReply: null,
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'req-ben',
|
||||
gmailMessageId: '<ben-1@mail.example>',
|
||||
threadId: 'thr-2',
|
||||
fromAddress: 'ben@example.org',
|
||||
fromName: 'Ben Berger',
|
||||
subject: 'Frage zur Haltung',
|
||||
bodyText: 'Guten Tag, was für ein Becken empfehlt ihr für zwei Rennmäuse?',
|
||||
receivedAt: '2026-06-04T09:00:00Z',
|
||||
status: 'InProgress',
|
||||
assignedContactId: null,
|
||||
draftReply: 'Hallo Ben, wir empfehlen mindestens 100×50 cm …',
|
||||
answeredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'req-clara',
|
||||
gmailMessageId: '<clara-1@mail.example>',
|
||||
threadId: null,
|
||||
fromAddress: 'clara@example.com',
|
||||
fromName: null,
|
||||
subject: 'Danke!',
|
||||
bodyText: 'Die beiden sind super angekommen — danke nochmal!',
|
||||
receivedAt: '2026-06-01T12:00:00Z',
|
||||
status: 'Answered',
|
||||
assignedContactId: 'con-meier',
|
||||
draftReply: null,
|
||||
answeredAt: '2026-06-02T08:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
gerbils,
|
||||
litters,
|
||||
enclosures,
|
||||
contacts,
|
||||
colorVarieties,
|
||||
healthRecords,
|
||||
weightRecords,
|
||||
pages,
|
||||
requests,
|
||||
aiConfigured: true,
|
||||
mailConfigured: true,
|
||||
contracts: [],
|
||||
saleAdConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** 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 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: /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).
|
||||
await page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||
|
||||
66
gerbil-manager-web/e2e/webseite-vorschau.spec.ts
Normal file
66
gerbil-manager-web/e2e/webseite-vorschau.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* WEB-3: Lokale Vorschau der öffentlichen Webseite — iframe lädt die
|
||||
* gerenderte Seite, Seiten-Wechsler (nur Veröffentlichte), Smartphone/
|
||||
* Desktop-Umschalter, Einstiege von der Übersicht.
|
||||
* Mock-gebunden (Seed-Seiten) → skipUnlessMock.
|
||||
*/
|
||||
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const tw = de.pages.webseite
|
||||
const tv = tw.vorschau
|
||||
|
||||
test.describe('Webseiten-Vorschau', () => {
|
||||
test('Vorschau öffnet die gerenderte Startseite im iframe', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite')
|
||||
await page.getByRole('link', { name: tv.button }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: tv.title })).toBeVisible()
|
||||
await expect(page.getByText(tv.intro)).toBeVisible()
|
||||
// Inhalt der gerenderten Seite (Mock-HTML) ist im iframe sichtbar
|
||||
const frame = page.frameLocator('.vorschau-iframe')
|
||||
await expect(frame.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Seiten-Wechsler listet nur Veröffentlichte und wechselt die Vorschau', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite/vorschau')
|
||||
|
||||
const select = page.getByLabel(tv.pageSelect)
|
||||
// 2 veröffentlichte Seed-Seiten; Entwürfe (z. B. „Über die Zucht“) fehlen
|
||||
await expect(select.locator('option')).toHaveCount(2)
|
||||
await expect(select.locator('option', { hasText: 'Über die Zucht' })).toHaveCount(0)
|
||||
|
||||
await select.selectOption({ label: 'Abgabetiere' })
|
||||
const frame = page.frameLocator('.vorschau-iframe')
|
||||
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Smartphone/Desktop-Umschalter ändert die Rahmenbreite', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite/vorschau')
|
||||
|
||||
// Standard: Smartphone-Rahmen
|
||||
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
|
||||
await page.getByRole('button', { name: tv.viewDesktop }).click()
|
||||
await expect(page.locator('.vorschau-frame--phone')).toHaveCount(0)
|
||||
await page.getByRole('button', { name: tv.viewPhone }).click()
|
||||
await expect(page.locator('.vorschau-frame--phone')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Übersicht: „Ansehen“ nur bei Veröffentlichten, öffnet die richtige Seite', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/webseite')
|
||||
|
||||
// Veröffentlichte Karte hat den Ansehen-Link, Entwurf nicht
|
||||
const abgabeCard = page.locator('.webseite-card', { hasText: 'Abgabetiere' })
|
||||
const draftCard = page.locator('.webseite-card', { hasText: 'Über die Zucht' })
|
||||
await expect(abgabeCard.getByRole('link', { name: tv.openPage })).toBeVisible()
|
||||
await expect(draftCard.getByRole('link', { name: tv.openPage })).toHaveCount(0)
|
||||
|
||||
await abgabeCard.getByRole('link', { name: tv.openPage }).click()
|
||||
await expect(page).toHaveURL(/\/webseite\/vorschau\?seite=abgabetiere/)
|
||||
const frame = page.frameLocator('.vorschau-iframe')
|
||||
await expect(frame.getByRole('heading', { name: 'Abgabetiere' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
57
gerbil-manager-web/e2e/webseite.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** WEB-0b: CMS-Verwaltung der öffentlichen Webseite — Smoke-Test. */
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.webseite
|
||||
|
||||
test('Webseiten-Übersicht zeigt die Seiten mit Status', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.website)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
const cards = page.locator('.webseite-card')
|
||||
await expect(cards).toHaveCount(6)
|
||||
|
||||
const start = page.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
await expect(start.getByText(t.statusPublished, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Block hinzufügen, bearbeiten und verschieben bleibt erhalten', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.website)
|
||||
|
||||
// Startseite öffnen (hat bereits zwei Blöcke).
|
||||
await page
|
||||
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
.getByRole('link', { name: t.edit })
|
||||
.click()
|
||||
await expect(page.getByRole('heading', { name: 'Startseite' })).toBeVisible()
|
||||
|
||||
const blocks = page.locator('.block-card')
|
||||
await expect(blocks).toHaveCount(2)
|
||||
|
||||
// Eine Überschrift hinzufügen -> jetzt drei Blöcke.
|
||||
await page.getByRole('button', { name: `+ ${t.blocks.types.Heading}` }).click()
|
||||
await expect(blocks).toHaveCount(3)
|
||||
|
||||
// Den neuen (letzten) Block beschriften und speichern.
|
||||
const last = blocks.last()
|
||||
await last.getByLabel(t.blocks.fields.headingText).fill('Neue Überschrift')
|
||||
await last.getByRole('button', { name: t.blocks.save }).click()
|
||||
await expect(last.getByText(t.blocks.saved, { exact: true })).toBeVisible()
|
||||
|
||||
// Den neuen Block nach oben verschieben (von Position 3 auf 2).
|
||||
await last.getByRole('button', { name: t.blocks.moveUp }).click()
|
||||
|
||||
// Neu laden über die Übersicht — der Mock ist zustandsbehaftet.
|
||||
await page.getByRole('link', { name: t.back }).click()
|
||||
await page
|
||||
.locator('.webseite-card', { hasText: 'Startseite' })
|
||||
.getByRole('link', { name: t.edit })
|
||||
.click()
|
||||
|
||||
// Drei Blöcke, und die neue Überschrift steht jetzt an zweiter Stelle.
|
||||
await expect(page.locator('.block-card')).toHaveCount(3)
|
||||
await expect(page.locator('.block-card').nth(1).getByLabel(t.blocks.fields.headingText)).toHaveValue(
|
||||
'Neue Überschrift',
|
||||
)
|
||||
})
|
||||
@@ -22,6 +22,11 @@ import HilfePage from './pages/HilfePage'
|
||||
import VertraegeListPage from './pages/VertraegeListPage'
|
||||
import VertragWizardPage from './pages/VertragWizardPage'
|
||||
import EinstellungenPage from './pages/EinstellungenPage'
|
||||
import WebseitePage from './pages/WebseitePage'
|
||||
import WebseiteEditorPage from './pages/WebseiteEditorPage'
|
||||
import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
||||
import AnfragenPage from './pages/AnfragenPage'
|
||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -65,6 +70,18 @@ export default function App() {
|
||||
<Route path="neu" element={<VertragWizardPage />} />
|
||||
</Route>
|
||||
<Route path="einstellungen" element={<EinstellungenPage />} />
|
||||
{/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite */}
|
||||
<Route path="webseite">
|
||||
<Route index element={<WebseitePage />} />
|
||||
{/* WEB-3: lokale Vorschau (statischer Pfad gewinnt vor :slug) */}
|
||||
<Route path="vorschau" element={<WebseiteVorschauPage />} />
|
||||
<Route path=":slug" element={<WebseiteEditorPage />} />
|
||||
</Route>
|
||||
{/* INBOX-1: Anfragen-Posteingang */}
|
||||
<Route path="anfragen">
|
||||
<Route index element={<AnfragenPage />} />
|
||||
<Route path=":id" element={<AnfrageDetailPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
24
gerbil-manager-web/src/api/__tests__/pages.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ADDABLE_BLOCK_TYPES, defaultBlockData, type BlockType } from '../pages'
|
||||
|
||||
describe('defaultBlockData', () => {
|
||||
it('liefert sinnvolle Startwerte je Block-Typ', () => {
|
||||
expect(defaultBlockData('Heading')).toEqual({ text: '', level: 2 })
|
||||
expect(defaultBlockData('RichText')).toEqual({ markdown: '' })
|
||||
expect(defaultBlockData('Image')).toEqual({ url: '', alt: '' })
|
||||
expect(defaultBlockData('Gallery')).toEqual({ images: [] })
|
||||
expect(defaultBlockData('ContactInfo')).toEqual({ name: '', email: '', phone: '', address: '' })
|
||||
expect(defaultBlockData('AbgabetiereList')).toEqual({ mode: 'auto', intro: '' })
|
||||
})
|
||||
|
||||
it('deckt jeden addbaren Typ ab', () => {
|
||||
for (const type of ADDABLE_BLOCK_TYPES) {
|
||||
expect(defaultBlockData(type)).toBeTypeOf('object')
|
||||
}
|
||||
})
|
||||
|
||||
it('bietet die dynamische AbgabetiereList NICHT zum manuellen Anlegen an', () => {
|
||||
const addable: readonly BlockType[] = ADDABLE_BLOCK_TYPES
|
||||
expect(addable).not.toContain('AbgabetiereList')
|
||||
})
|
||||
})
|
||||
167
gerbil-manager-web/src/api/pages.ts
Normal file
167
gerbil-manager-web/src/api/pages.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* WEB-0b: CMS-Inhalte der öffentlichen Webseite (Seiten + Blöcke).
|
||||
*
|
||||
* Backend-Vertrag (GerbilManagerWebAPI/Endpoints/CmsEndpoints.cs):
|
||||
* - GET /pages -> PageSummary[] (kein Paging!)
|
||||
* - GET /pages/{slug} -> Page (inkl. Blöcke)
|
||||
* - PUT /pages/{id} -> 204
|
||||
* - POST /pages/{pageId}/blocks -> 201 Block
|
||||
* - PUT /blocks/{id} -> 204
|
||||
* - DELETE /blocks/{id} -> 204
|
||||
* - PUT /pages/{pageId}/blocks/order -> 204 (Body: { blockIds })
|
||||
*
|
||||
* Enums kommen dank globalem JsonStringEnumConverter als STRINGS
|
||||
* ("Draft"/"Published", "Heading"/"RichText"/…). Block.data ist ein
|
||||
* typ-spezifisches JSON-Objekt (siehe BlockData-Typen unten).
|
||||
*/
|
||||
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 BlockType =
|
||||
| 'Heading'
|
||||
| 'RichText'
|
||||
| 'Image'
|
||||
| 'Gallery'
|
||||
| 'ContactInfo'
|
||||
| 'AbgabetiereList'
|
||||
|
||||
/** Block-Typen, die die Hand-Editier-Oberfläche neu anlegen kann (in Anzeige-Reihenfolge). */
|
||||
export const ADDABLE_BLOCK_TYPES: readonly BlockType[] = [
|
||||
'Heading',
|
||||
'RichText',
|
||||
'Image',
|
||||
'Gallery',
|
||||
'ContactInfo',
|
||||
] as const
|
||||
|
||||
// ── Typ-spezifische Block-Daten ────────────────────────────────────────────
|
||||
export interface HeadingData {
|
||||
text: string
|
||||
level: 2 | 3
|
||||
}
|
||||
export interface RichTextData {
|
||||
/** Markdown-Quelltext (wird erst beim Veröffentlichen gerendert + bereinigt). */
|
||||
markdown: string
|
||||
}
|
||||
export interface ImageData {
|
||||
url: string
|
||||
alt: string
|
||||
}
|
||||
export interface GalleryImage {
|
||||
url: string
|
||||
alt: string
|
||||
}
|
||||
export interface GalleryData {
|
||||
images: GalleryImage[]
|
||||
}
|
||||
export interface ContactInfoData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
address: string
|
||||
}
|
||||
export interface AbgabetiereListData {
|
||||
/** auto = aus den aktuellen Abgabetieren aufgelöst; manual = von Hand gepflegt. */
|
||||
mode: 'auto' | 'manual'
|
||||
intro: string
|
||||
}
|
||||
|
||||
export type BlockData = Record<string, unknown>
|
||||
|
||||
export interface Block {
|
||||
id: string
|
||||
order: number
|
||||
type: BlockType
|
||||
data: BlockData
|
||||
}
|
||||
|
||||
export interface PageSummary {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: PageStatus
|
||||
}
|
||||
|
||||
export interface Page extends PageSummary {
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
export interface PageInput {
|
||||
slug: string
|
||||
title: string
|
||||
seoDescription: string | null
|
||||
status: PageStatus
|
||||
}
|
||||
|
||||
export interface BlockInput {
|
||||
type: BlockType
|
||||
data: BlockData
|
||||
order?: number
|
||||
}
|
||||
|
||||
/** Sinnvolle Startwerte für einen neu angelegten Block. */
|
||||
export function defaultBlockData(type: BlockType): BlockData {
|
||||
switch (type) {
|
||||
case 'Heading':
|
||||
return { text: '', level: 2 }
|
||||
case 'RichText':
|
||||
return { markdown: '' }
|
||||
case 'Image':
|
||||
return { url: '', alt: '' }
|
||||
case 'Gallery':
|
||||
return { images: [] }
|
||||
case 'ContactInfo':
|
||||
return { name: '', email: '', phone: '', address: '' }
|
||||
case 'AbgabetiereList':
|
||||
return { mode: 'auto', intro: '' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── API-Aufrufe ──────────────────────────────────────────────────────────────
|
||||
export function listPages(): Promise<PageSummary[]> {
|
||||
return api.get<PageSummary[]>(`${CMS}/pages`)
|
||||
}
|
||||
|
||||
export function getPage(slug: string): Promise<Page> {
|
||||
return api.get<Page>(`${CMS}/pages/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function updatePage(id: string, input: PageInput): Promise<void> {
|
||||
return api.put<void>(`${CMS}/pages/${id}`, input)
|
||||
}
|
||||
|
||||
export function addBlock(pageId: string, input: BlockInput): Promise<Block> {
|
||||
return api.post<Block>(`${CMS}/pages/${pageId}/blocks`, input)
|
||||
}
|
||||
|
||||
export function updateBlock(id: string, input: BlockInput): Promise<void> {
|
||||
return api.put<void>(`${CMS}/blocks/${id}`, input)
|
||||
}
|
||||
|
||||
export function deleteBlock(id: string): Promise<void> {
|
||||
return api.delete(`${CMS}/blocks/${id}`)
|
||||
}
|
||||
|
||||
export function reorderBlocks(pageId: string, blockIds: string[]): Promise<void> {
|
||||
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}`
|
||||
}
|
||||
87
gerbil-manager-web/src/api/requests.ts
Normal file
87
gerbil-manager-web/src/api/requests.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang (/api/requests, Backend INBOX-0/2/3).
|
||||
*
|
||||
* Triage-Hinweis: PUT setzt assignedContactId UNBEDINGT aus dem Input
|
||||
* (Backend überschreibt mit null, wenn das Feld fehlt) — beim reinen
|
||||
* Status-Wechsel also IMMER die aktuelle Zuordnung mitsenden.
|
||||
*/
|
||||
import { ApiError, api } from './client'
|
||||
import { toQueryString, type GridifyQuery } from './gridify'
|
||||
import type { Paged } from './types'
|
||||
|
||||
/** C#-Enum RequestStatus — Stringnamen auf dem Draht. */
|
||||
export type RequestStatus = 'New' | 'InProgress' | 'Assigned' | 'Answered' | 'Abandoned'
|
||||
export const REQUEST_STATUSES: RequestStatus[] = [
|
||||
'New',
|
||||
'InProgress',
|
||||
'Assigned',
|
||||
'Answered',
|
||||
'Abandoned',
|
||||
]
|
||||
|
||||
export interface InboxRequest {
|
||||
id: string
|
||||
gmailMessageId: string
|
||||
threadId: string | null
|
||||
fromAddress: string
|
||||
fromName: string | null
|
||||
subject: string | null
|
||||
bodyText: string | null
|
||||
receivedAt: string
|
||||
status: RequestStatus
|
||||
assignedContactId: string | null
|
||||
draftReply: string | null
|
||||
answeredAt: string | null
|
||||
}
|
||||
|
||||
export interface RequestTriage {
|
||||
status?: RequestStatus | null
|
||||
assignedContactId?: string | null
|
||||
}
|
||||
|
||||
/** Ergebnis von POST /api/requests/sync. */
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
/** null = ok; "MailNotConfigured" | "MailAuthFailed" | … */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const BASE = '/api/requests'
|
||||
|
||||
export function listRequests(query: GridifyQuery): Promise<Paged<InboxRequest>> {
|
||||
return api.get<Paged<InboxRequest>>(`${BASE}${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function getRequest(id: string): Promise<InboxRequest> {
|
||||
return api.get<InboxRequest>(`${BASE}/${id}`)
|
||||
}
|
||||
|
||||
export function triageRequest(id: string, triage: RequestTriage): Promise<void> {
|
||||
return api.put<void>(`${BASE}/${id}`, triage)
|
||||
}
|
||||
|
||||
export function syncRequests(): Promise<SyncResult> {
|
||||
return api.post<SyncResult>(`${BASE}/sync`, {})
|
||||
}
|
||||
|
||||
/** KI-Entwurf erzeugen (INBOX-2); 503 {code:'AiKeyMissing'} solange unkonfiguriert. */
|
||||
export function draftReply(id: string): Promise<InboxRequest> {
|
||||
return api.post<InboxRequest>(`${BASE}/${id}/draft`, {})
|
||||
}
|
||||
|
||||
/** (Bearbeitete) Antwort senden (INBOX-3); setzt serverseitig Answered. */
|
||||
export function sendReply(id: string, body: string): Promise<InboxRequest> {
|
||||
return api.post<InboxRequest>(`${BASE}/${id}/send`, { body })
|
||||
}
|
||||
|
||||
/**
|
||||
* ProblemDetails-Titel aus einem ApiError lesen (Send-Endpunkt meldet
|
||||
* MailNotConfigured/MailAuthFailed über `title`, nicht `code`).
|
||||
*/
|
||||
export function problemTitle(err: unknown): string | null {
|
||||
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'title' in err.body) {
|
||||
const title = (err.body as { title: unknown }).title
|
||||
return typeof title === 'string' ? title : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -47,6 +47,13 @@ export interface Gerbil {
|
||||
*/
|
||||
characterTraits?: string[] | null
|
||||
characterNote?: string | null
|
||||
/**
|
||||
* Residency (Bestand): true = part of the own clan (Kleine Chaoten), false =
|
||||
* external/pedigree-only ancestor imported for the Stammbaum. Gridify-filterable
|
||||
* (`isResident==true`); the Tiere list defaults to resident-only. Absent = treat
|
||||
* as resident (backend default true).
|
||||
*/
|
||||
isResident?: boolean
|
||||
}
|
||||
|
||||
/** Payload for POST /gerbils. */
|
||||
@@ -68,6 +75,7 @@ export interface CreateGerbil {
|
||||
notes?: string | null
|
||||
characterTraits?: string[] | null
|
||||
characterNote?: string | null
|
||||
isResident?: boolean
|
||||
}
|
||||
|
||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||
|
||||
@@ -22,10 +22,14 @@ const SECONDARY: NavItem[] = [
|
||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||
// INBOX-1: Anfragen-Posteingang
|
||||
{ to: '/anfragen', label: de.nav.requests, icon: '📨' },
|
||||
{ to: '/statistik', label: de.nav.statistics, icon: '📊' },
|
||||
// FEAT-13: Abgabeverträge + Zuchtprofil
|
||||
{ to: '/vertraege', label: de.nav.contracts, icon: '📄' },
|
||||
{ to: '/einstellungen', label: de.nav.settings, icon: '⚙️' },
|
||||
// WEB-0b: CMS-Verwaltung der öffentlichen Webseite
|
||||
{ to: '/webseite', label: de.nav.website, icon: '🌐' },
|
||||
{ to: '/hilfe', label: de.nav.help, icon: '❓' },
|
||||
]
|
||||
|
||||
|
||||
@@ -65,28 +65,30 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
||||
</button>
|
||||
|
||||
{showGenotypes && (
|
||||
<table className="genotype-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t.genotypeLabel}</th>
|
||||
<th>{t.genotypePreview}</th>
|
||||
<th>{t.probability}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.offspring.map((o) => (
|
||||
<tr key={o.genotype}>
|
||||
<td>
|
||||
<code>{o.genotype}</code>
|
||||
</td>
|
||||
<td>{o.farbschlag}</td>
|
||||
<td>
|
||||
{o.probability.percent} <small>({o.probability.text})</small>
|
||||
</td>
|
||||
<div className="genotype-table-scroll">
|
||||
<table className="genotype-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t.genotypeLabel}</th>
|
||||
<th>{t.genotypePreview}</th>
|
||||
<th>{t.probability}</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.offspring.map((o) => (
|
||||
<tr key={o.genotype}>
|
||||
<td>
|
||||
<code>{o.genotype}</code>
|
||||
</td>
|
||||
<td>{o.farbschlag}</td>
|
||||
<td>
|
||||
{o.probability.percent} <small>({o.probability.text})</small>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,16 @@ export function formatDate(iso: string | null | undefined): string {
|
||||
if (!y || !m || !d) return iso
|
||||
return `${d}.${m}.${y}`
|
||||
}
|
||||
|
||||
/** INBOX-1: ISO-Zeitstempel → "TT.MM.JJJJ, HH:MM" (lokale Zeit, de-DE). */
|
||||
export function formatDateTime(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
toJSON,
|
||||
fromJSON,
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
} from '../catalog'
|
||||
|
||||
/** The first 18 entries are the frozen contract names (must round-trip exactly). */
|
||||
const FROZEN_COUNT = 18
|
||||
const FROZEN_COUNT = 17
|
||||
import { breed } from '../breed'
|
||||
import { GeneticsWarningCode } from '../warnings'
|
||||
|
||||
@@ -64,6 +65,7 @@ describe('Genotype serialization', () => {
|
||||
P: ['p', 'P'],
|
||||
Sp: ['sp', 'sp'],
|
||||
Re: ['re', 're'],
|
||||
Sls: ['sl', 'sl'],
|
||||
})
|
||||
expect(g.A).toEqual(['A', 'a'])
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
@@ -171,14 +173,17 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
|
||||
// Colourpoint marked + dilute + grey combo not in the catalog.
|
||||
const match = farbschlagFor(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))
|
||||
// E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo.
|
||||
const match = farbschlagFor(fromDisplayString('aa CC dd EE gg pp spsp rere'))
|
||||
expect(match.unknown).toBe(true)
|
||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||
})
|
||||
|
||||
it('has the expected catalogue coverage (GEN-2: frozen 18 + 55 portal varieties)', () => {
|
||||
expect(CATALOG_SIZE).toBe(73)
|
||||
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
|
||||
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
|
||||
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
|
||||
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
|
||||
expect(CATALOG_SIZE).toBe(66)
|
||||
})
|
||||
|
||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
@@ -204,7 +209,11 @@ describe('Farbschlag catalog', () => {
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
for (const entry of BASE_COLORS) {
|
||||
for (const [locus, value] of Object.entries(entry.tokens)) {
|
||||
expect(LOCI[locus as LocusKey].alleles).toContain(value)
|
||||
// GEN-3f: a token may be a heterozygous pair "x/y" (e.g. C: 'cchm/ch'
|
||||
// for the het colourpoints Siam/Zobel-Hell) — validate each allele.
|
||||
for (const allele of value.split('/')) {
|
||||
expect(LOCI[locus as LocusKey].alleles).toContain(allele)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -220,7 +229,7 @@ describe('Farbschlag catalog', () => {
|
||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||
expect(genotypeToFarbschlag(wildType())).toBe('Agouti')
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD EE GG pp spsp rere'))).toBe('Platin')
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa cchmcchm dd ee gg PP spsp rere'))).toBe(
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC dd EE gg pp spsp rere'))).toBe(
|
||||
'Unbekannter Farbschlag',
|
||||
)
|
||||
})
|
||||
@@ -240,6 +249,7 @@ describe('Partially-unknown parents (wildcards)', () => {
|
||||
P: ['P', 'P'],
|
||||
Sp: ['sp', 'sp'],
|
||||
Re: ['re', 're'],
|
||||
Sls: ['sl', 'sl'],
|
||||
})
|
||||
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
||||
const result = breed(father, mother)
|
||||
@@ -263,3 +273,253 @@ describe('Probabilities are exact fractions summing to 1', () => {
|
||||
expect(toNumber(sum)).toBeCloseTo(1, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: Uw=G alias', () => {
|
||||
it('Uwuw parses as Gg, UwUw as GG', () => {
|
||||
expect(fromDisplayString('AA CC DD EE Uwuw PP spsp rere').G).toEqual(['G', 'g'])
|
||||
expect(fromDisplayString('AA CC DD EE UwUw PP spsp rere').G).toEqual(['G', 'G'])
|
||||
expect(fromDisplayString('AA CC DD EE uwuw PP spsp rere').G).toEqual(['g', 'g'])
|
||||
})
|
||||
|
||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||
'AA CC DD EE Gg PP spsp rere',
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
it('S(l)s(l) and WP both parse to the Sls heterozygote Slsl', () => {
|
||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere S(l)s(l)').Sls).toEqual(['Sl', 'sl'])
|
||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
||||
})
|
||||
|
||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||||
'AA CC DD EE GG PP spsp rere Slsl',
|
||||
)
|
||||
})
|
||||
|
||||
it('WP × WP: S(l)S(l) is lethal — removed, renormalized, SLS_LETHAL warning', () => {
|
||||
const parent = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
|
||||
const result = breed(parent, parent)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('SlSl'))).toBe(true)
|
||||
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
|
||||
expect(sum).toBeCloseTo(1, 10)
|
||||
// survivors 2/3 Slsl : 1/3 slsl
|
||||
const wp = result.offspring.find((o) => o.genotype.includes('Slsl'))!
|
||||
expect(wp.probability.text).toBe('2/3')
|
||||
const warn = result.warnings.find((w) => w.code === GeneticsWarningCode.SlsLethal)
|
||||
expect(warn?.detail?.youngLostFraction).toBe('1/4')
|
||||
})
|
||||
|
||||
it('Sp × Sls → Superschecke deafness warning', () => {
|
||||
const father = fromDisplayString('AA CC DD EE GG PP Spsp rere')
|
||||
const mother = fromDisplayString('AA CC DD EE GG PP spsp rere Slsl')
|
||||
const result = breed(father, mother)
|
||||
expect(
|
||||
result.warnings.some((w) => w.code === GeneticsWarningCode.SuperscheckeDeaf),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
||||
})
|
||||
|
||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere dea WFNZ').deaf).toBe(true)
|
||||
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere Dea').deaf).toBe(false)
|
||||
expect(extractGenotypeFlags('AA CC DD EE GG PP spsp rere WFNZ RV').tags).toEqual(['WFNZ', 'RV'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3a: Schimmel catalog fix (breeder C5)', () => {
|
||||
it('efef base -> Orangeschimmel (not Schwarzschimmel)', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||
'Orangeschimmel',
|
||||
)
|
||||
})
|
||||
it('efef pp -> Rotaugenschimmel, efef gg -> Silberschimmel', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG pp spsp rere'))).toBe(
|
||||
'Rotaugenschimmel',
|
||||
)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef gg PP spsp rere'))).toBe(
|
||||
'Silberschimmel',
|
||||
)
|
||||
})
|
||||
it('no Schwarzschimmel anywhere in the catalog', () => {
|
||||
expect(BASE_COLORS.some((e) => e.name === 'Schwarzschimmel')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
||||
})
|
||||
it("'?' and '-' inputs are equivalent", () => {
|
||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||
'Aa C- DD EE GG Pp spsp rere',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
)
|
||||
})
|
||||
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||
expect(farbschlagFor(fromDisplayString('a- C- D- ee G- P-')).unknown).toBe(false)
|
||||
expect(farbschlagFor(fromDisplayString('A- C- D- efef G- P-')).unknown).toBe(false)
|
||||
})
|
||||
it('unknown allele no longer blocks a match (wildcard, never Unbekannt)', () => {
|
||||
// C unknown must not force Unbekannt — it resolves to SOME named variety.
|
||||
expect(farbschlagFor(fromDisplayString('aa C- DD EE GG PP spsp rere')).unknown).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
|
||||
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
||||
expect(name).toBe('Schwarz')
|
||||
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||
})
|
||||
|
||||
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
||||
// 'sp-' (one sp + unknown) -> sp/sp -> no Schecke (naive most-dominant would wrongly add it).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
||||
})
|
||||
|
||||
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3e: C-locus colourpoint naming', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('A- + cchm/cchm -> CP-<base colour> (CP-Silberagouti example)', () => {
|
||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
})
|
||||
|
||||
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||
})
|
||||
|
||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('chch stays Hermelin (aa) / Himalaya (A-); full C => no CP', () => {
|
||||
expect(name('aa chch DD EE GG PP spsp rere')).toBe('Hermelin')
|
||||
expect(name('AA chch DD EE GG PP spsp rere')).toBe('Himalaya')
|
||||
expect(name('AA CC DD EE GG PP spsp rere')).toBe('Agouti') // one full C -> no colourpoint
|
||||
})
|
||||
|
||||
it('colourpoint composes with the Schecke modifier', () => {
|
||||
expect(name('aa cchmcchm DD EE GG PP Spsp rere')).toBe('Marder Schecke')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her live data)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||
|
||||
it('every CP- name her data uses exists as a catalog row (import name-match)', () => {
|
||||
for (const n of [
|
||||
'CP-Agouti', 'CP-Silberagouti', 'CP-Algierfuchs', 'CP-Polarfuchs',
|
||||
'CP-Fuchs', 'CP-Fuchs-Hell', 'CP-Blaufuchs', 'CP-Orangeschimmel',
|
||||
'Marder', 'Siam', 'Zobel', 'Zobel-Hell',
|
||||
]) {
|
||||
expect(has(n)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('the removed portal suffix-names are gone (collapsed)', () => {
|
||||
for (const n of [
|
||||
'Agouti CP', 'Agouti CP-Hell', 'Silberagouti CP', 'Algierfuchs CP',
|
||||
'Polarfuchs CP', 'Blaufuchs CP', 'Kohlfuchs CP', 'Kohlfuchsschimmel CP',
|
||||
'Marder dd', 'Zobel dd', 'Siam (Marder-Hell)', 'Agouti dd CP',
|
||||
]) {
|
||||
expect(has(n)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('the engine computes the agouti CP- names her data uses (E-family fallback covers fox/dilute)', () => {
|
||||
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 ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||||
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||||
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
|
||||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
|
||||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
|
||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
|
||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||
|
||||
it('all new -Hell het entries exist in catalog', () => {
|
||||
for (const n of [
|
||||
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
|
||||
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
|
||||
]) {
|
||||
expect(has(n)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||
})
|
||||
|
||||
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
|
||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
|
||||
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||||
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,9 +20,15 @@
|
||||
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
||||
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
||||
*/
|
||||
import { LOCUS_ORDER, type LocusKey } from './loci'
|
||||
import { makeGenotype, toDisplayString, wildType, type AllelePair, type Genotype } from './genotype'
|
||||
import { phenotypeTokens, type PhenotypeTokens } from './phenotype'
|
||||
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||
import {
|
||||
makeGenotype,
|
||||
toDisplayString,
|
||||
wildType,
|
||||
WILDCARD,
|
||||
type AllelePair,
|
||||
type Genotype,
|
||||
} from './genotype'
|
||||
|
||||
export interface FarbschlagEntry {
|
||||
/** German variety name. FROZEN for the original 18 (DB keys). */
|
||||
@@ -41,12 +47,14 @@ export interface FarbschlagEntry {
|
||||
* portal collision group.
|
||||
*/
|
||||
export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
// ── Frozen 18 (names are DB-key contract; do not rename) ──
|
||||
// ── Frozen names (DB-key contract; do not rename) ──
|
||||
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
||||
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
||||
// to the frozen-name rule; the ColorVariety seed drops it too.
|
||||
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
||||
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
||||
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
||||
{ name: 'Schwarzschimmel', english: 'Black Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
{ name: 'Rotaugenschimmel', english: 'Red-Eyed Roan', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotaugen-schimmel.jpg' },
|
||||
{ name: 'Agouti', english: 'Golden Agouti', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-mit-erklaerung-der-genloci.JPG' },
|
||||
{ name: 'Schwarz', english: 'Black', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'schwarz.jpg' },
|
||||
@@ -68,55 +76,60 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'C-Separator', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'p' }, image: 'c-separator.jpg' },
|
||||
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
|
||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||
{ name: 'Siam (Marder-Hell)', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||
{ name: 'Schimmel (Orangeschimmel)', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||
{ name: 'Agouti CP-Hell', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp-hell.JPG' },
|
||||
{ name: 'Blaufuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-cp.jpg' },
|
||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
||||
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
||||
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
||||
{ name: 'Polarfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell-cp.jpg' },
|
||||
{ name: 'Kohlfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel.jpg' },
|
||||
{ name: 'Blaufuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'blaufuchsschimmel.jpg' },
|
||||
{ name: 'Kohlfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell.jpg' },
|
||||
{ name: 'Goldfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs-hell.jpg' },
|
||||
{ name: 'Goldfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'goldfuchsschimmel.jpg' },
|
||||
{ name: 'Gold-Hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold-hell.jpg' },
|
||||
{ name: 'Siam (Marder-Hell) dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell-dd.jpg' },
|
||||
{ name: 'Marder dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'marder-dd.jpg' },
|
||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||
{ name: 'Silberagouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd-cp.jpg' },
|
||||
{ name: 'Silberagouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-hell-dd-cp.jpg' },
|
||||
{ name: 'Agouti dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd-cp.jpg' },
|
||||
{ name: 'Agouti-Hell dd CP', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-hell-dd-cp.jpg' },
|
||||
{ name: 'Blaufuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-hell.jpeg' },
|
||||
{ name: 'Rotfuchsschimmel', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'p' }, image: 'rotfuchsschimmel.jpg' },
|
||||
{ name: 'Polarfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-hell.jpeg' },
|
||||
{ name: 'Kohlfuchsschimmel, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-hell.jpg' },
|
||||
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
||||
{ name: 'Zobel dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-dd.jpg' },
|
||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||
{ name: 'Kohlfuchs CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-cp.jpg' },
|
||||
{ name: 'Algierfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||
{ name: 'Silberagouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||
{ name: 'Agouti CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||
{ name: 'Algierfuchs-Hell CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell-cp.jpg' },
|
||||
{ name: 'Kohlfuchs,hell CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-cp.jpg' },
|
||||
{ name: 'Polarfuchs CP', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Zobel-Hell dd', tokens: { A: 'a', C: 'cchm', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell-dd.jpg' },
|
||||
{ name: 'Kohlfuchsschimmel CP', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'kohlfuchsschimmel-cp.JPG' },
|
||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
|
||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
|
||||
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
|
||||
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
|
||||
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
|
||||
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
|
||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||
]
|
||||
|
||||
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||
@@ -130,26 +143,110 @@ export interface FarbschlagMatch {
|
||||
readonly unknown: boolean
|
||||
}
|
||||
|
||||
function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean {
|
||||
/**
|
||||
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
||||
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
||||
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
||||
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
|
||||
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
|
||||
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
|
||||
* raw pair, which runs before this.)
|
||||
*/
|
||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
|
||||
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
if (locus === 'E') {
|
||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
||||
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||
}
|
||||
return dominantAllele(locus, x, y)
|
||||
}
|
||||
|
||||
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
return (Object.keys(entry.tokens) as LocusKey[]).every(
|
||||
(locus) => tokens[locus] === entry.tokens[locus],
|
||||
(locus) => locusToken(g, locus) === entry.tokens[locus],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
|
||||
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
|
||||
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
|
||||
* Returns null when E is dominant (full colour) or fully unknown.
|
||||
*/
|
||||
function eFamily(g: Genotype): string | null {
|
||||
const [x, y] = g.E
|
||||
if (x === 'e' && y === 'e') return 'Fuchs'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
||||
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
||||
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
|
||||
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
||||
function baseColourFor(g: Genotype): string | null {
|
||||
const family = eFamily(g)
|
||||
const base = family
|
||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||
return base?.name ?? family
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
||||
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
||||
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
||||
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
||||
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
|
||||
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
|
||||
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
|
||||
*/
|
||||
function colourpointName(g: Genotype): string | null {
|
||||
const c = resolvedPair(g, 'C')
|
||||
if (c.includes('C')) return null // a full C allele => full colour, no CP
|
||||
if (c[0] === 'ch' && c[1] === 'ch') return null // chch -> base matcher (Hermelin/Himalaya)
|
||||
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||
const agouti = resolvedPair(g, 'A').includes('A')
|
||||
if (!agouti) {
|
||||
const [g1, g2] = resolvedPair(g, 'G')
|
||||
const grey = g1 === 'g' && g2 === 'g'
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
||||
}
|
||||
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
const tokens = phenotypeTokens(g)
|
||||
const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null
|
||||
|
||||
const modifiers: string[] = []
|
||||
if (tokens.Sp === 'Sp') modifiers.push('Schecke')
|
||||
if (tokens.Re === 'Re') modifiers.push('Rex')
|
||||
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||
|
||||
if (!base) {
|
||||
const baseName = colourpointName(g) ?? baseColourFor(g)
|
||||
if (!baseName) {
|
||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||
}
|
||||
const name = [base.name, ...modifiers].join(' ')
|
||||
return { name, base, unknown: false }
|
||||
const name = [baseName, ...modifiers].join(' ')
|
||||
return { name, base: null, unknown: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +271,14 @@ export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const token = entry.tokens[locus]
|
||||
out[locus] = token ? [token, token] : base[locus]
|
||||
if (!token) {
|
||||
out[locus] = base[locus]
|
||||
continue
|
||||
}
|
||||
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
|
||||
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||
out[locus] = [a, b]
|
||||
}
|
||||
return makeGenotype(out)
|
||||
}
|
||||
|
||||
@@ -1,440 +1,406 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Schwarzschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": null
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 5,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 6,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 7,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 8,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 9,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 10,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 11,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 12,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 13,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 14,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "dd Gold",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 16,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd Platin",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 17,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 18,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 19,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 20,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 21,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 22,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam (Marder-Hell)",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 26,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 27,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Schimmel (Orangeschimmel)",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti CP-Hell",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "agouti-cp-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs-Hell CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "polarfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Siam (Marder-Hell) dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "siam-marder-hell-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "marder-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "silberagouti-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti-Hell dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "silberagouti-hell-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "agouti-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti-Hell dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-hell-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 58,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "zobel-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 60,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 61,
|
||||
"image": "kohlfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 62,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 63,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Agouti CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 64,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs-Hell CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 65,
|
||||
"image": "algierfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs,hell CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 66,
|
||||
"image": "kohlfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 67,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 68,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 69,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 70,
|
||||
"image": "zobel-hell-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 71,
|
||||
"image": "kohlfuchsschimmel-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 72,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
}
|
||||
]
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 5,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"english": "Black",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 7,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"english": "Slate",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 9,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"english": "Blue",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 11,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"english": "Lilac",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 13,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"english": "Argente Nutmeg",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "dd Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 16,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 18,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"sortOrder": 20,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 61
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 63
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
}
|
||||
]
|
||||
|
||||
@@ -64,18 +64,30 @@ export function makeGenotype(input: Record<LocusKey, AllelePair>): Genotype {
|
||||
export function wildType(): Genotype {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
// Wild-type is homozygous for the most dominant allele, EXCEPT the
|
||||
// marker loci Sp/Re whose wild form is the recessive (unmarked) allele.
|
||||
// Wild-type is homozygous for the most dominant allele, EXCEPT the marker
|
||||
// loci Sp/Re/Sls whose wild form is the recessive (unmarked) allele.
|
||||
const alleles = LOCI[locus].alleles
|
||||
const a = locus === 'Sp' || locus === 'Re' ? alleles[alleles.length - 1] : alleles[0]
|
||||
const marker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const a = marker ? alleles[alleles.length - 1] : alleles[0]
|
||||
out[locus] = [a, a]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". */
|
||||
/**
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||
* convention) — e.g. ['C','?'] renders "C-".
|
||||
*/
|
||||
export function toDisplayString(g: Genotype): string {
|
||||
return LOCUS_ORDER.map((locus) => g[locus][0] + g[locus][1]).join(' ')
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/** Stable JSON-storable object (already the in-memory shape; returned as a copy). */
|
||||
@@ -114,18 +126,81 @@ function splitToken(token: string): [string, string] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere") back into a Genotype.
|
||||
* Tokens may be given in any order; each token must belong to a distinct locus.
|
||||
* GEN-3a: tokens that are NOT genotype loci — health/provenance metadata that may
|
||||
* appear in a herd-book genotype string. Stripped on parse (see extractGenotypeFlags).
|
||||
* - dea/Dea/taub = deafness flag (after spsp); DP/DarkPatch = non-Mendelian patch flag
|
||||
* - WFNZ/RV/GV = provenance/breeding-method annotations
|
||||
*/
|
||||
const FLAG_TOKENS = new Set(['DP', 'DarkPatch', 'dea', 'Dea', 'taub', 'WFNZ', 'RV', 'GV'])
|
||||
|
||||
/**
|
||||
* Normalize one whitespace-token to canonical allele symbols, or null if it is a
|
||||
* non-genotype flag/metadata token (to be stripped):
|
||||
* - Uw/uw -> G/g (international Underwhite == German Grey locus)
|
||||
* - S(l)/s(l) -> Sl/sl (second spotting locus notation)
|
||||
* - WP -> Slsl (WP is the visible S(l)s(l) heterozygote)
|
||||
*/
|
||||
function normalizeToken(tok: string): string | null {
|
||||
if (FLAG_TOKENS.has(tok)) return null
|
||||
let t = tok
|
||||
if (t === 'WP') t = 'Slsl'
|
||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
||||
t = t.replace(/-/g, '?')
|
||||
return t
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical normalized genotype string (flags/metadata removed, Uw/S(l)/WP
|
||||
* resolved). Exported so the import pipeline (GEN-3b) can mirror this exactly.
|
||||
*/
|
||||
export function normalizeGenotypeString(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(normalizeToken)
|
||||
.filter((t): t is string => t !== null)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract non-Punkett flags from a raw genotype string: deafness (dea/taub =
|
||||
* deaf, Dea = hearing) and provenance/pattern tags (WFNZ/RV/GV/DP).
|
||||
*/
|
||||
export function extractGenotypeFlags(input: string): { deaf?: boolean; tags: string[] } {
|
||||
const tokens = input.trim().split(/\s+/).filter(Boolean)
|
||||
let deaf: boolean | undefined
|
||||
const tags: string[] = []
|
||||
for (const tok of tokens) {
|
||||
if (tok === 'dea' || tok === 'taub') deaf = true
|
||||
else if (tok === 'Dea') deaf = false
|
||||
else if (tok === 'DP' || tok === 'DarkPatch' || tok === 'WFNZ' || tok === 'RV' || tok === 'GV')
|
||||
tags.push(tok)
|
||||
}
|
||||
return { deaf, tags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a display string ("Aa CC Dd EE GG Pp Spsp rere [Slsl]") back into a
|
||||
* Genotype. Tokens may be in any order; each must belong to a distinct locus.
|
||||
* Uw/S(l)/WP are normalized and flag/metadata tokens (dea, WFNZ, …) are stripped.
|
||||
* Missing loci default to wild-type.
|
||||
*/
|
||||
export function fromDisplayString(input: string): Genotype {
|
||||
const tokens = input.trim().split(/\s+/).filter(Boolean)
|
||||
const acc = {} as Record<LocusKey, AllelePair>
|
||||
for (const token of tokens) {
|
||||
for (const raw of tokens) {
|
||||
const token = normalizeToken(raw)
|
||||
if (token === null) continue // flag/metadata token — not a locus
|
||||
const [a, b] = splitToken(token)
|
||||
const refAllele = a === WILDCARD ? b : a
|
||||
if (refAllele === WILDCARD) {
|
||||
throw new Error(`Token "${token}" is fully unknown; cannot infer its locus`)
|
||||
// Fully-unknown token ("--"/"??", e.g. a positional placeholder): the locus
|
||||
// can't be inferred — skip it (defaults to wild-type), don't throw.
|
||||
continue
|
||||
}
|
||||
const locus = ALLELE_TO_LOCUS[refAllele]
|
||||
if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`)
|
||||
|
||||
@@ -25,6 +25,7 @@ interface LethalRule {
|
||||
|
||||
const LETHAL_RULES: readonly LethalRule[] = [
|
||||
{ locus: 'Sp', allele: 'Sp', kind: 'lethal', warning: GeneticsWarningCode.ScheckeLethal },
|
||||
{ locus: 'Sls', allele: 'Sl', kind: 'lethal', warning: GeneticsWarningCode.SlsLethal },
|
||||
{ locus: 'Re', allele: 'Re', kind: 'semi', warning: GeneticsWarningCode.RexSemiLethal },
|
||||
]
|
||||
|
||||
@@ -67,14 +68,23 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
|
||||
? survivors
|
||||
: survivors.map((e) => ({ value: e.value, probability: divide(e.probability, survivingMass) }))
|
||||
|
||||
if (lethalMass.num > 0) {
|
||||
warnings.push({
|
||||
code: GeneticsWarningCode.ScheckeLethal,
|
||||
detail: {
|
||||
youngLostFraction: toString(lethalMass),
|
||||
youngLostPercent: Number(((lethalMass.num / lethalMass.den) * 100).toFixed(2)),
|
||||
},
|
||||
})
|
||||
// One lethal warning PER lethal rule that actually removed young (so SpSp ->
|
||||
// ScheckeLethal and S(l)S(l) -> SlsLethal are reported distinctly).
|
||||
for (const rule of LETHAL_RULES) {
|
||||
if (rule.kind !== 'lethal') continue
|
||||
const mass = dist.reduce<Fraction>(
|
||||
(acc, e) => (isHomozygous(e.value, rule.locus, rule.allele) ? add(acc, e.probability) : acc),
|
||||
ZERO,
|
||||
)
|
||||
if (mass.num > 0) {
|
||||
warnings.push({
|
||||
code: rule.warning,
|
||||
detail: {
|
||||
youngLostFraction: toString(mass),
|
||||
youngLostPercent: Number(((mass.num / mass.den) * 100).toFixed(2)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Semi-lethal: warn if any surviving genotype is homozygous for a semi-lethal allele.
|
||||
@@ -95,5 +105,22 @@ export function applyLethality(dist: DistEntry<Genotype>[]): LethalityResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Superschecke: surviving young carrying BOTH spotting markers (Sp present and
|
||||
// S(l) present) are very-high-white and deafness-prone — info warning.
|
||||
const superMass = distribution.reduce<Fraction>((acc, e) => {
|
||||
const hasSp = e.value.Sp.includes('Sp')
|
||||
const hasSl = e.value.Sls.includes('Sl')
|
||||
return hasSp && hasSl ? add(acc, e.probability) : acc
|
||||
}, ZERO)
|
||||
if (superMass.num > 0) {
|
||||
warnings.push({
|
||||
code: GeneticsWarningCode.SuperscheckeDeaf,
|
||||
detail: {
|
||||
affectedFraction: toString(superMass),
|
||||
affectedPercent: Number(((superMass.num / superMass.den) * 100).toFixed(2)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { distribution, warnings }
|
||||
}
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
* - de.wikibooks.org/wiki/Die_Rennmaus/_Farbvarianten_und_Farbgenetik
|
||||
*/
|
||||
|
||||
/** Canonical locus keys, in conventional display order. */
|
||||
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re'] as const
|
||||
/**
|
||||
* Canonical locus keys, in conventional display order. Sls (second spotting
|
||||
* locus) is appended LAST so legacy 8-locus genotype strings still parse — a
|
||||
* missing Sls token defaults to wild-type sl/sl.
|
||||
*/
|
||||
export const LOCUS_ORDER = ['A', 'C', 'D', 'E', 'G', 'P', 'Sp', 'Re', 'Sls'] as const
|
||||
export type LocusKey = (typeof LOCUS_ORDER)[number]
|
||||
|
||||
export interface LocusDef {
|
||||
@@ -33,9 +37,12 @@ export interface LocusDef {
|
||||
* E = full extension
|
||||
* ef = Schimmel/roan (progressive whitening)
|
||||
* e = Fox (suppresses eumelanin)
|
||||
* Sp/Re are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
|
||||
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
|
||||
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
|
||||
* Sp/Re/Sls are dominant markers, lethal/semi-lethal when homozygous (see lethality.ts):
|
||||
* Sp = Schecke (checkered); checkered animals are always Spsp, SpSp dies in utero.
|
||||
* Re = Rex (curly coat); rex animals are Re-, ReRe is semi-lethal.
|
||||
* Sls = second spotting locus (S(l), WP/Minimalschecke). S(l)s(l) het = the WP
|
||||
* phenotype; S(l)S(l) homozygous = lethal (Rumpback/megacolon). Sp + Sls
|
||||
* together => Superschecke (very high white, deafness-prone).
|
||||
*/
|
||||
export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
|
||||
A: { key: 'A', nameDe: 'Agouti', alleles: ['A', 'a'] },
|
||||
@@ -46,6 +53,7 @@ export const LOCI: Readonly<Record<LocusKey, LocusDef>> = {
|
||||
P: { key: 'P', nameDe: 'Rotaugenaufhellung (Pink-Eye)', alleles: ['P', 'p'] },
|
||||
Sp: { key: 'Sp', nameDe: 'Schecke', alleles: ['Sp', 'sp'] },
|
||||
Re: { key: 'Re', nameDe: 'Rex', alleles: ['Re', 're'] },
|
||||
Sls: { key: 'Sls', nameDe: 'Zweite Scheckung (WP)', alleles: ['Sl', 'sl'] },
|
||||
}
|
||||
|
||||
/** Set of all valid allele symbols, longest-first (for maximal-munch parsing). */
|
||||
|
||||
@@ -10,6 +10,10 @@ export const GeneticsWarningCode = {
|
||||
ScheckeLethal: 'SCHECKE_LETHAL',
|
||||
/** Rex × Rex: ReRe is semi-lethal; reduced viability of homozygous young. */
|
||||
RexSemiLethal: 'REX_SEMI_LETHAL',
|
||||
/** WP × WP: S(l)S(l) is prenatal-lethal (Rumpback/megacolon); fewer live young. */
|
||||
SlsLethal: 'SLS_LETHAL',
|
||||
/** Sp + Sls together -> Superschecke: very high white, deafness-prone (info). */
|
||||
SuperscheckeDeaf: 'SUPERSCHECKE_DEAF',
|
||||
} as const
|
||||
|
||||
export type GeneticsWarningCode =
|
||||
|
||||
@@ -77,6 +77,10 @@ a {
|
||||
padding: 1rem;
|
||||
max-width: 60rem;
|
||||
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 */
|
||||
@@ -330,6 +334,24 @@ textarea {
|
||||
color: #3a6ea5;
|
||||
}
|
||||
|
||||
/* BESTAND-FILTER: subtle marker for external (non-Bestand) animals. Inline next
|
||||
to the name, so it should not be pushed to the grid edge like the status badge. */
|
||||
.badge--external {
|
||||
justify-self: start;
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
background: #f0f0f0;
|
||||
color: #777;
|
||||
border: 1px dashed #bbb;
|
||||
}
|
||||
|
||||
/* Checkbox-style filter (label text + box on one row), vs. the stacked .field. */
|
||||
.field--check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.gerbil-card {
|
||||
grid-template-columns: 2fr 1fr 1.5fr 1fr auto;
|
||||
@@ -541,8 +563,14 @@ textarea {
|
||||
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 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -553,6 +581,7 @@ textarea {
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.genotype-table code {
|
||||
@@ -741,4 +770,4 @@ textarea {
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
245
gerbil-manager-web/src/pages/AnfrageDetailPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* INBOX-1: Anfrage-Detail — Nachricht lesen, Triage (Status / Verwerfen /
|
||||
* Abnehmer zuordnen), KI-Antwortentwurf (INBOX-2) und Versand (INBOX-3).
|
||||
*
|
||||
* - Triage-PUT überschreibt assignedContactId immer → bei jedem Update wird
|
||||
* die aktuelle Zuordnung mitgesendet (Backend-Vertrag, siehe api/requests.ts).
|
||||
* - KI entwirft NUR (human-in-the-loop): Entwurf landet editierbar im
|
||||
* Textfeld; gesendet wird ausschließlich nach Bestätigung.
|
||||
* - 503 AiKeyMissing / MailNotConfigured / MailAuthFailed → deutsche Hinweise
|
||||
* (gleiches Muster wie die Verkaufstext-KI).
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { errorCode } from '../api/client'
|
||||
import {
|
||||
REQUEST_STATUSES,
|
||||
draftReply,
|
||||
getRequest,
|
||||
problemTitle,
|
||||
sendReply,
|
||||
triageRequest,
|
||||
type InboxRequest,
|
||||
type RequestStatus,
|
||||
} from '../api/requests'
|
||||
import { listContactsPaged } from '../api/contacts'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { StatusBadge } from './AnfragenPage'
|
||||
import { formatDateTime } from '../format/labels'
|
||||
import './anfragen.css'
|
||||
|
||||
export default function AnfrageDetailPage() {
|
||||
const t = de.pages.anfragen
|
||||
const td = t.detail
|
||||
const { id = '' } = useParams()
|
||||
|
||||
const request = useApi(() => getRequest(id), [id])
|
||||
const contacts = useApi(() => listContactsPaged({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
|
||||
|
||||
// Antwort-Text: lokaler Entwurf; aus draftReply vorbefüllt, sobald geladen.
|
||||
const [reply, setReply] = useState('')
|
||||
const [replyInitFor, setReplyInitFor] = useState<string | null>(null)
|
||||
if (request.data && replyInitFor !== request.data.id) {
|
||||
setReplyInitFor(request.data.id)
|
||||
setReply(request.data.draftReply ?? '')
|
||||
}
|
||||
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
|
||||
/** Aktuellen Server-Stand nach einer Mutation übernehmen (ohne Neu-Laden). */
|
||||
const [override, setOverride] = useState<InboxRequest | null>(null)
|
||||
const current = override && override.id === id ? override : request.data
|
||||
|
||||
const triage = useMutation((next: { status?: RequestStatus; assignedContactId: string | null }) =>
|
||||
triageRequest(id, next),
|
||||
)
|
||||
async function applyTriage(next: { status?: RequestStatus; assignedContactId: string | null }) {
|
||||
setNotice(null)
|
||||
const result = await triage.run(next)
|
||||
if (result.ok && current) {
|
||||
setOverride({
|
||||
...current,
|
||||
status: next.status ?? current.status,
|
||||
assignedContactId: next.assignedContactId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draft = useMutation(() => draftReply(id))
|
||||
async function onDraft() {
|
||||
setHint(null)
|
||||
setNotice(null)
|
||||
const result = await draft.run()
|
||||
if (result.ok) {
|
||||
setOverride(result.value)
|
||||
setReply(result.value.draftReply ?? '')
|
||||
return
|
||||
}
|
||||
const code = errorCode(result.cause)
|
||||
if (code === 'AiKeyMissing') setHint(td.aiKeyMissing)
|
||||
else if (code === 'AiUpstreamError') setHint(td.aiUpstreamError)
|
||||
// sonst: draft.error treibt den Alert
|
||||
}
|
||||
|
||||
const send = useMutation(() => sendReply(id, reply))
|
||||
async function onSend() {
|
||||
setHint(null)
|
||||
setNotice(null)
|
||||
if (reply.trim() === '') {
|
||||
setHint(td.sendEmptyBody)
|
||||
return
|
||||
}
|
||||
if (!window.confirm(td.sendConfirm)) return
|
||||
const result = await send.run()
|
||||
if (result.ok) {
|
||||
setOverride(result.value)
|
||||
setNotice(td.sent)
|
||||
return
|
||||
}
|
||||
const title = problemTitle(result.cause)
|
||||
if (title === 'MailNotConfigured') setHint(t.mailNotConfigured)
|
||||
else if (title === 'MailAuthFailed') setHint(t.mailAuthFailed)
|
||||
}
|
||||
|
||||
if (request.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (request.error || !current) {
|
||||
return (
|
||||
<section className="page">
|
||||
<p className="muted">{request.error ?? td.notFound}</p>
|
||||
<Link to="/anfragen" className="btn">
|
||||
{td.back}
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const r = current
|
||||
const contactItems = contacts.data?.items ?? []
|
||||
const mutationError = triage.error ?? draft.error ?? send.error
|
||||
|
||||
return (
|
||||
<section className="page anfrage-detail">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{r.subject ?? td.noSubject}</h2>
|
||||
<StatusBadge status={r.status} />
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to="/anfragen" className="btn">
|
||||
{td.back}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="def-list">
|
||||
<div className="def-row">
|
||||
<dt>{td.from}</dt>
|
||||
<dd>{r.fromName ? `${r.fromName} <${r.fromAddress}>` : r.fromAddress}</dd>
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{td.receivedAt}</dt>
|
||||
<dd>{formatDateTime(r.receivedAt)}</dd>
|
||||
</div>
|
||||
{r.answeredAt && (
|
||||
<div className="def-row">
|
||||
<dt>{td.answeredAt}</dt>
|
||||
<dd>{formatDateTime(r.answeredAt)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<h3>{td.message}</h3>
|
||||
<div className="anfrage-body">{r.bodyText?.trim() ? r.bodyText : td.noBody}</div>
|
||||
|
||||
<h3>{td.triage}</h3>
|
||||
{mutationError && <div className="alert alert--error">{mutationError}</div>}
|
||||
<div className="anfrage-triage">
|
||||
<label className="field">
|
||||
<span>{td.statusLabel}</span>
|
||||
<select
|
||||
value={r.status}
|
||||
disabled={triage.pending}
|
||||
onChange={(e) =>
|
||||
applyTriage({
|
||||
status: e.target.value as RequestStatus,
|
||||
assignedContactId: r.assignedContactId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t.statusLabels[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{td.assignContact}</span>
|
||||
<select
|
||||
value={r.assignedContactId ?? ''}
|
||||
disabled={triage.pending || contacts.loading}
|
||||
onChange={(e) => {
|
||||
const contactId = e.target.value || null
|
||||
// Zuordnung setzt den Status auf „Zugeordnet“ (Beantwortet bleibt).
|
||||
applyTriage({
|
||||
status:
|
||||
contactId && r.status !== 'Answered' ? 'Assigned' : undefined,
|
||||
assignedContactId: contactId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<option value="">{td.assignNone}</option>
|
||||
{contactItems.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{r.status !== 'Abandoned' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger anfrage-abandon"
|
||||
disabled={triage.pending}
|
||||
onClick={() => {
|
||||
if (!window.confirm(td.abandonConfirm)) return
|
||||
applyTriage({ status: 'Abandoned', assignedContactId: r.assignedContactId })
|
||||
}}
|
||||
>
|
||||
{td.abandon}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3>{td.reply}</h3>
|
||||
{notice && <div className="alert">{notice}</div>}
|
||||
{hint && <div className="alert">{hint}</div>}
|
||||
<div className="form anfrage-reply">
|
||||
<textarea
|
||||
className="anfrage-reply__text"
|
||||
placeholder={td.replyPlaceholder}
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
/>
|
||||
<p className="muted anfrage-reply__hint">{td.draftHint}</p>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={onDraft} disabled={draft.pending}>
|
||||
{draft.pending ? td.drafting : td.draftButton}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={onSend}
|
||||
disabled={send.pending}
|
||||
>
|
||||
{send.pending ? td.sending : td.send}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
154
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
154
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* INBOX-1: Anfragen-Posteingang — Liste mit Status-Filter (Gridify) und
|
||||
* manuellem Gmail-Abruf („Anfragen abrufen“, POST /api/requests/sync).
|
||||
* Solange Gmail unkonfiguriert ist, antwortet der Sync mit MailNotConfigured
|
||||
* → freundlicher deutscher Hinweis statt Fehler.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import {
|
||||
REQUEST_STATUSES,
|
||||
listRequests,
|
||||
syncRequests,
|
||||
type RequestStatus,
|
||||
} from '../api/requests'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDateTime } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
import './anfragen.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function StatusBadge({ status }: { status: RequestStatus }) {
|
||||
return (
|
||||
<span className={`badge anfrage-badge anfrage-badge--${status.toLowerCase()}`}>
|
||||
{de.pages.anfragen.statusLabels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AnfragenPage() {
|
||||
const t = de.pages.anfragen
|
||||
const [status, setStatus] = useState<RequestStatus | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const requests = useApi(
|
||||
() =>
|
||||
listRequests({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
orderBy: 'receivedAt desc',
|
||||
filter: status === '' ? undefined : `status==${status}`,
|
||||
}),
|
||||
[page, status],
|
||||
)
|
||||
|
||||
const sync = useMutation(() => syncRequests())
|
||||
async function onSync() {
|
||||
setNotice(null)
|
||||
const result = await sync.run()
|
||||
if (!result.ok) return // sync.error treibt den Alert
|
||||
if (result.value.error === 'MailNotConfigured') setNotice(t.mailNotConfigured)
|
||||
else if (result.value.error === 'MailAuthFailed') setNotice(t.mailAuthFailed)
|
||||
else {
|
||||
setNotice(t.syncImported(result.value.imported))
|
||||
if (result.value.imported > 0) requests.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
{requests.data && <p className="muted">{t.countText(requests.data.totalCount)}</p>}
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={onSync} disabled={sync.pending}>
|
||||
{sync.pending ? t.syncing : t.sync}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{notice && <div className="alert">{notice}</div>}
|
||||
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||
|
||||
{/* Status-Filter */}
|
||||
<FilterPanel
|
||||
activeCount={status !== '' ? 1 : 0}
|
||||
onReset={() => { setStatus(''); setPage(1) }}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t.detail.statusLabel}</span>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as RequestStatus | '')
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<option value="">{t.filterAll}</option>
|
||||
{REQUEST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t.statusLabels[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</FilterPanel>
|
||||
|
||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{requests.error && (
|
||||
<div className="alert alert--error">
|
||||
<span>{requests.error}</span>
|
||||
<button type="button" className="btn" onClick={requests.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length === 0 && (
|
||||
<p className="muted">{status === '' ? t.empty : t.emptyFiltered}</p>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.items.length > 0 && (
|
||||
<ul className="card-list">
|
||||
{requests.data.items.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link to={`/anfragen/${r.id}`} className="gerbil-card anfrage-card">
|
||||
<span className="gerbil-card__name">{r.fromName ?? r.fromAddress}</span>
|
||||
<StatusBadge status={r.status} />
|
||||
<span className="anfrage-card__subject">
|
||||
{r.subject ?? de.pages.anfragen.detail.noSubject}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDateTime(r.receivedAt)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{requests.data && requests.data.totalCount > PAGE_SIZE && (
|
||||
<nav className="pager" aria-label="Seitennavigation">
|
||||
<button type="button" className="btn" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
{de.common.previous}
|
||||
</button>
|
||||
<span>
|
||||
{de.common.page} {page} {de.common.of}{' '}
|
||||
{Math.max(1, Math.ceil(requests.data.totalCount / PAGE_SIZE))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={page >= Math.ceil(requests.data.totalCount / PAGE_SIZE)}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{de.common.next}
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -104,6 +104,11 @@ export default function GerbilDetailPage() {
|
||||
<GerbilProfilePhoto gerbilId={g.id} />
|
||||
<h2>{g.name}</h2>
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||
{de.pages.gerbils.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${g.id}/bearbeiten`} className="btn btn--primary">
|
||||
|
||||
@@ -24,6 +24,7 @@ interface FormState {
|
||||
receiverContactId: string
|
||||
genotype: string
|
||||
notes: string
|
||||
isResident: boolean
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -41,6 +42,7 @@ const EMPTY: FormState = {
|
||||
receiverContactId: '',
|
||||
genotype: '',
|
||||
notes: '',
|
||||
isResident: true,
|
||||
}
|
||||
|
||||
function formFromGerbil(g: {
|
||||
@@ -58,6 +60,7 @@ function formFromGerbil(g: {
|
||||
receiverContactId: string | null
|
||||
genotype: string | null
|
||||
notes: string | null
|
||||
isResident?: boolean | null
|
||||
}): FormState {
|
||||
return {
|
||||
name: g.name,
|
||||
@@ -74,6 +77,7 @@ function formFromGerbil(g: {
|
||||
receiverContactId: g.receiverContactId ?? '',
|
||||
genotype: g.genotype ?? '',
|
||||
notes: g.notes ?? '',
|
||||
isResident: g.isResident ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +170,7 @@ export default function GerbilFormPage() {
|
||||
receiverContactId: nn(form.receiverContactId),
|
||||
genotype: nn(form.genotype),
|
||||
notes: nn(form.notes),
|
||||
isResident: form.isResident,
|
||||
}
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||
@@ -355,6 +360,15 @@ export default function GerbilFormPage() {
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="field field--check" title={t.form.isResidentHint}>
|
||||
<span>{t.form.isResidentLabel}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isResident}
|
||||
onChange={(e) => set('isResident', e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
||||
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
import './gerbils.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -32,6 +33,8 @@ export default function GerbilsPage() {
|
||||
const [gender, setGender] = useState<Gender | ''>('')
|
||||
const [colorVarietyId, setColorVarietyId] = useState('')
|
||||
const [originBreeder, setOriginBreeder] = useState('')
|
||||
// BESTAND-FILTER: default to the own clan (Bestand); opt in to external ancestors.
|
||||
const [showExternal, setShowExternal] = useState(false)
|
||||
const [sort, setSort] = useState<SortKey>('nameAsc')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
@@ -50,6 +53,9 @@ export default function GerbilsPage() {
|
||||
gender && condition({ field: 'gender', op: '==', value: gender }),
|
||||
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
||||
originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }),
|
||||
// Default view = own clan (Bestand). Unless "externe Ahnen einblenden" is on,
|
||||
// restrict to resident animals (server-side, so paging/counts stay correct).
|
||||
!showExternal && condition({ field: 'isResident', op: '==', value: true }),
|
||||
)
|
||||
const orderBy = SORT_ORDER_BY[sort]
|
||||
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
||||
@@ -62,6 +68,7 @@ export default function GerbilsPage() {
|
||||
setGender('')
|
||||
setColorVarietyId('')
|
||||
setOriginBreeder('')
|
||||
setShowExternal(false)
|
||||
setPage(1)
|
||||
setSort('nameAsc')
|
||||
}
|
||||
@@ -76,6 +83,14 @@ export default function GerbilsPage() {
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
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".
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const toggleSelect = (id: string) =>
|
||||
@@ -112,15 +127,20 @@ export default function GerbilsPage() {
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="filters">
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
<FilterPanel
|
||||
searchField={
|
||||
<input
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||
aria-label={t.fields.name}
|
||||
/>
|
||||
}
|
||||
activeCount={activeFilterCount}
|
||||
onReset={resetFilters}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t.filters.status}</span>
|
||||
<select
|
||||
@@ -186,10 +206,15 @@ export default function GerbilsPage() {
|
||||
<option value="birthAsc">{t.sort.birthAsc}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn" onClick={resetFilters}>
|
||||
{t.filters.reset}
|
||||
</button>
|
||||
</div>
|
||||
<label className="field field--check" title={t.filters.showExternalHint}>
|
||||
<span>{t.filters.showExternal}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showExternal}
|
||||
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</FilterPanel>
|
||||
|
||||
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{gerbils.error && (
|
||||
@@ -234,7 +259,14 @@ export default function GerbilsPage() {
|
||||
aria-label={g.name}
|
||||
/>
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{g.name}</span>
|
||||
<span className="gerbil-card__name">
|
||||
{g.name}
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={t.externalTitle}>
|
||||
{t.externalBadge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>
|
||||
{statusLabel(g.status)}
|
||||
</span>
|
||||
|
||||
439
gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Normal file
439
gerbil-manager-web/src/pages/WebseiteEditorPage.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* WEB-0b: Seiten-Editor der öffentlichen Webseite.
|
||||
* - Kopf: Titel, SEO-Beschreibung, Status (Entwurf/Veröffentlicht).
|
||||
* - Geordneter Block-Editor: Blöcke hinzufügen, bearbeiten, verschieben, löschen.
|
||||
*
|
||||
* Reihenfolge/Anlegen/Löschen rufen das Backend auf und laden danach neu; der
|
||||
* Editor wird über `key` neu montiert, sodass der lokale Zustand frisch aus den
|
||||
* geladenen Daten initialisiert wird (keine Prop→State-Synchronisation per Effekt).
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import {
|
||||
ADDABLE_BLOCK_TYPES,
|
||||
addBlock,
|
||||
defaultBlockData,
|
||||
deleteBlock,
|
||||
getPage,
|
||||
reorderBlocks,
|
||||
updateBlock,
|
||||
updatePage,
|
||||
type Block,
|
||||
type BlockData,
|
||||
type BlockType,
|
||||
type GalleryImage,
|
||||
type Page,
|
||||
type PageStatus,
|
||||
} from '../api/pages'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import './webseite.css'
|
||||
|
||||
const asStr = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||
|
||||
export default function WebseiteEditorPage() {
|
||||
const { slug = '' } = useParams<{ slug: string }>()
|
||||
const page = useApi(() => getPage(slug), [slug])
|
||||
|
||||
if (page.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (page.error || !page.data) {
|
||||
return (
|
||||
<section className="page">
|
||||
<h2>{de.pages.webseite.title}</h2>
|
||||
<div className="alert alert--error">
|
||||
<span>{page.error}</span>
|
||||
<button type="button" className="btn" onClick={page.reload}>
|
||||
{de.common.retry}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Signatur der Blöcke -> erzwingt Neu-Montage nach Reload (frische Init).
|
||||
const signature = page.data.blocks.map((b) => `${b.id}:${b.order}`).join('|')
|
||||
return <PageEditor key={signature} page={page.data} reload={page.reload} />
|
||||
}
|
||||
|
||||
function PageEditor({ page, reload }: { page: Page; reload: () => void }) {
|
||||
const t = de.pages.webseite
|
||||
const [title, setTitle] = useState(page.title)
|
||||
const [seo, setSeo] = useState(page.seoDescription ?? '')
|
||||
const [status, setStatus] = useState<PageStatus>(page.status)
|
||||
const [savedHead, setSavedHead] = useState(false)
|
||||
|
||||
const saveHead = useMutation(() =>
|
||||
updatePage(page.id, {
|
||||
slug: page.slug,
|
||||
title,
|
||||
seoDescription: seo.trim() === '' ? null : seo,
|
||||
status,
|
||||
}),
|
||||
)
|
||||
async function onSaveHead() {
|
||||
const r = await saveHead.run()
|
||||
if (r.ok) {
|
||||
setSavedHead(true)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = [...page.blocks].sort((a, b) => a.order - b.order)
|
||||
|
||||
const adder = useMutation((type: BlockType) =>
|
||||
addBlock(page.id, { type, data: defaultBlockData(type) }),
|
||||
)
|
||||
async function onAdd(type: BlockType) {
|
||||
const r = await adder.run(type)
|
||||
if (r.ok) reload()
|
||||
}
|
||||
|
||||
const remover = useMutation((id: string) => deleteBlock(id))
|
||||
async function onDelete(id: string) {
|
||||
if (!window.confirm(t.blocks.confirmDelete)) return
|
||||
const r = await remover.run(id)
|
||||
if (r.ok) reload()
|
||||
}
|
||||
|
||||
const reorder = useMutation((ids: string[]) => reorderBlocks(page.id, ids))
|
||||
async function onMove(index: number, dir: -1 | 1) {
|
||||
const target = index + dir
|
||||
if (target < 0 || target >= blocks.length) return
|
||||
const ids = blocks.map((b) => b.id)
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
const r = await reorder.run(ids)
|
||||
if (r.ok) reload()
|
||||
}
|
||||
|
||||
const busy = adder.pending || remover.pending || reorder.pending
|
||||
|
||||
return (
|
||||
<section className="page">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{page.title}</h2>
|
||||
<p className="muted">/{page.slug}</p>
|
||||
</div>
|
||||
<div className="head-actions">
|
||||
<Link to="/webseite" className="btn">
|
||||
{t.back}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Seiten-Kopf bearbeiten */}
|
||||
<div className="webseite-section">
|
||||
<label className="field">
|
||||
<span className="field-label">{t.editor.pageTitleLabel}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value)
|
||||
setSavedHead(false)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{t.editor.seoLabel}</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={seo}
|
||||
onChange={(e) => {
|
||||
setSeo(e.target.value)
|
||||
setSavedHead(false)
|
||||
}}
|
||||
/>
|
||||
<span className="field-hint">{t.editor.seoHint}</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{t.statusLabel}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as PageStatus)
|
||||
setSavedHead(false)
|
||||
}}
|
||||
>
|
||||
<option value="Draft">{t.statusDraft}</option>
|
||||
<option value="Published">{t.statusPublished}</option>
|
||||
</select>
|
||||
</label>
|
||||
{saveHead.error && <div className="alert alert--error">{saveHead.error}</div>}
|
||||
<div className="head-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={onSaveHead} disabled={saveHead.pending}>
|
||||
{t.editor.save}
|
||||
</button>
|
||||
{savedHead && <span className="saved-hint">{t.editor.saved}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block-Editor */}
|
||||
<h3 className="webseite-blocks-title">{t.blocks.sectionTitle}</h3>
|
||||
{(reorder.error || remover.error || adder.error) && (
|
||||
<div className="alert alert--error">{reorder.error ?? remover.error ?? adder.error}</div>
|
||||
)}
|
||||
|
||||
{blocks.length === 0 ? (
|
||||
<p className="muted">{t.blocks.empty}</p>
|
||||
) : (
|
||||
<ol className="block-list">
|
||||
{blocks.map((b, i) => (
|
||||
<li key={b.id}>
|
||||
<BlockCard
|
||||
block={b}
|
||||
index={i}
|
||||
count={blocks.length}
|
||||
busy={busy}
|
||||
onMove={onMove}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<div className="block-add" role="group" aria-label={t.blocks.addLabel}>
|
||||
<span className="field-label">{t.blocks.addLabel}</span>
|
||||
{ADDABLE_BLOCK_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={adder.pending}
|
||||
onClick={() => onAdd(type)}
|
||||
>
|
||||
+ {t.blocks.types[type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockCard({
|
||||
block,
|
||||
index,
|
||||
count,
|
||||
busy,
|
||||
onMove,
|
||||
onDelete,
|
||||
}: {
|
||||
block: Block
|
||||
index: number
|
||||
count: number
|
||||
busy: boolean
|
||||
onMove: (index: number, dir: -1 | 1) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const t = de.pages.webseite.blocks
|
||||
const [data, setData] = useState<BlockData>(block.data)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const save = useMutation(() => updateBlock(block.id, { type: block.type, data }))
|
||||
async function onSave() {
|
||||
const r = await save.run()
|
||||
if (r.ok) setSaved(true)
|
||||
}
|
||||
const set = (patch: BlockData) => {
|
||||
setData((d) => ({ ...d, ...patch }))
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="block-card">
|
||||
<div className="block-card__head">
|
||||
<span className="block-card__type">{t.types[block.type]}</span>
|
||||
<span className="block-card__tools">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
aria-label={t.moveUp}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => onMove(index, -1)}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--icon"
|
||||
aria-label={t.moveDown}
|
||||
disabled={busy || index === count - 1}
|
||||
onClick={() => onMove(index, 1)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(block.id)}
|
||||
>
|
||||
{t.delete}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<BlockFields type={block.type} data={data} set={set} />
|
||||
|
||||
{save.error && <div className="alert alert--error">{save.error}</div>}
|
||||
<div className="head-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={onSave} disabled={save.pending}>
|
||||
{t.save}
|
||||
</button>
|
||||
{saved && <span className="saved-hint">{t.saved}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockFields({
|
||||
type,
|
||||
data,
|
||||
set,
|
||||
}: {
|
||||
type: BlockType
|
||||
data: BlockData
|
||||
set: (patch: BlockData) => void
|
||||
}) {
|
||||
const f = de.pages.webseite.blocks.fields
|
||||
|
||||
switch (type) {
|
||||
case 'Heading':
|
||||
return (
|
||||
<>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.headingText}</span>
|
||||
<input className="input" value={asStr(data.text)} onChange={(e) => set({ text: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.headingLevel}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={data.level === 3 ? '3' : '2'}
|
||||
onChange={(e) => set({ level: Number(e.target.value) })}
|
||||
>
|
||||
<option value="2">{f.levelMain}</option>
|
||||
<option value="3">{f.levelSub}</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
|
||||
case 'RichText':
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{f.markdown}</span>
|
||||
<textarea
|
||||
className="input block-markdown"
|
||||
rows={6}
|
||||
value={asStr(data.markdown)}
|
||||
onChange={(e) => set({ markdown: e.target.value })}
|
||||
/>
|
||||
<span className="field-hint">{f.markdownHint}</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
case 'Image':
|
||||
return (
|
||||
<>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.imageUrl}</span>
|
||||
<input className="input" value={asStr(data.url)} onChange={(e) => set({ url: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.imageAlt}</span>
|
||||
<input className="input" value={asStr(data.alt)} onChange={(e) => set({ alt: e.target.value })} />
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
|
||||
case 'Gallery':
|
||||
return <GalleryFields data={data} set={set} />
|
||||
|
||||
case 'ContactInfo':
|
||||
return (
|
||||
<>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.contactName}</span>
|
||||
<input className="input" value={asStr(data.name)} onChange={(e) => set({ name: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.contactEmail}</span>
|
||||
<input className="input" value={asStr(data.email)} onChange={(e) => set({ email: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.contactPhone}</span>
|
||||
<input className="input" value={asStr(data.phone)} onChange={(e) => set({ phone: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.contactAddress}</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={asStr(data.address)}
|
||||
onChange={(e) => set({ address: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
|
||||
case 'AbgabetiereList':
|
||||
return (
|
||||
<>
|
||||
<p className="field-hint">{f.abgabeAutoNote}</p>
|
||||
<label className="field">
|
||||
<span className="field-label">{f.abgabeIntro}</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={asStr(data.intro)}
|
||||
onChange={(e) => set({ intro: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function GalleryFields({ data, set }: { data: BlockData; set: (patch: BlockData) => void }) {
|
||||
const f = de.pages.webseite.blocks.fields
|
||||
const images: GalleryImage[] = Array.isArray(data.images) ? (data.images as GalleryImage[]) : []
|
||||
|
||||
const update = (i: number, patch: Partial<GalleryImage>) =>
|
||||
set({ images: images.map((img, j) => (j === i ? { ...img, ...patch } : img)) })
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<span className="field-label">{f.galleryImages}</span>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="gallery-row">
|
||||
<input
|
||||
className="input"
|
||||
placeholder={f.imageUrl}
|
||||
value={asStr(img.url)}
|
||||
onChange={(e) => update(i, { url: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder={f.imageAlt}
|
||||
value={asStr(img.alt)}
|
||||
onChange={(e) => update(i, { alt: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
onClick={() => set({ images: images.filter((_, j) => j !== i) })}
|
||||
>
|
||||
{f.galleryRemove}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn" onClick={() => set({ images: [...images, { url: '', alt: '' }] })}>
|
||||
+ {f.galleryAdd}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
gerbil-manager-web/src/pages/WebseitePage.tsx
Normal file
78
gerbil-manager-web/src/pages/WebseitePage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/** WEB-0b: Übersicht der Webseiten-Seiten mit Status (Entwurf/Veröffentlicht). */
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { listPages, type PageStatus } from '../api/pages'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import './webseite.css'
|
||||
|
||||
function StatusBadge({ status }: { status: PageStatus }) {
|
||||
const t = de.pages.webseite
|
||||
const published = status === 'Published'
|
||||
return (
|
||||
<span className={published ? 'status-badge status-badge--published' : 'status-badge status-badge--draft'}>
|
||||
{published ? t.statusPublished : t.statusDraft}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WebseitePage() {
|
||||
const t = de.pages.webseite
|
||||
const pages = useApi(() => listPages(), [])
|
||||
|
||||
if (pages.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (pages.error || !pages.data) {
|
||||
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">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h2>{t.title}</h2>
|
||||
<p className="muted">{t.intro}</p>
|
||||
</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>
|
||||
|
||||
{pages.data.length === 0 ? (
|
||||
<p className="muted">{t.empty}</p>
|
||||
) : (
|
||||
<ul className="card-list">
|
||||
{pages.data.map((p) => (
|
||||
<li key={p.id} className="gerbil-card webseite-card">
|
||||
<span className="gerbil-card__name">{p.title}</span>
|
||||
<span className="gerbil-card__meta">/{p.slug}</span>
|
||||
<span className="webseite-card__actions">
|
||||
<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">
|
||||
{t.edit}
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
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 { useApi } from '../hooks/useApi'
|
||||
import { formatDate } from '../format/labels'
|
||||
import { FilterPanel } from '../components/FilterPanel'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
|
||||
|
||||
{tab === 'litters' && (
|
||||
<>
|
||||
<div className="filters">
|
||||
<FilterPanel
|
||||
activeCount={year !== '' ? 1 : 0}
|
||||
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t.filterYear}</span>
|
||||
<select
|
||||
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
|
||||
<option value="dateAsc">{t.sort.dateAsc}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</FilterPanel>
|
||||
|
||||
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{litters.error && (
|
||||
|
||||
84
gerbil-manager-web/src/pages/anfragen.css
Normal file
84
gerbil-manager-web/src/pages/anfragen.css
Normal file
@@ -0,0 +1,84 @@
|
||||
/* INBOX-1: Anfragen-Posteingang — seitenspezifische Stile
|
||||
(Standing-Rule-2-Muster: eigene Datei statt index.css). */
|
||||
|
||||
/* ── Listen-Karte ── */
|
||||
|
||||
.anfrage-card {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.anfrage-card__subject {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Status-Badges (eigene Farben je Status) ── */
|
||||
|
||||
.anfrage-badge--new {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anfrage-badge--inprogress {
|
||||
background: #e7eef6;
|
||||
color: #3a6ea5;
|
||||
}
|
||||
|
||||
.anfrage-badge--assigned {
|
||||
background: #efe6f6;
|
||||
color: #7a4ea5;
|
||||
}
|
||||
|
||||
.anfrage-badge--answered {
|
||||
background: #e6f2e6;
|
||||
color: #3a7a3a;
|
||||
}
|
||||
|
||||
.anfrage-badge--abandoned {
|
||||
background: #ececec;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ── Detail ── */
|
||||
|
||||
.anfrage-body {
|
||||
white-space: pre-wrap;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.anfrage-triage {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
margin: 0.5rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.anfrage-triage .field {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.anfrage-abandon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.anfrage-reply {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.anfrage-reply__text {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.anfrage-reply__hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
123
gerbil-manager-web/src/pages/webseite.css
Normal file
123
gerbil-manager-web/src/pages/webseite.css
Normal file
@@ -0,0 +1,123 @@
|
||||
/* WEB-0b: CMS-Verwaltung der öffentlichen Webseite. */
|
||||
|
||||
/* ── Seitenliste ── */
|
||||
.webseite-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.status-badge--draft {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.status-badge--published {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* ── Editor: Kopf + Blöcke ── */
|
||||
.webseite-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.saved-hint {
|
||||
align-self: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.webseite-blocks-title {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.block-list {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.block-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.9rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.block-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.block-card__type {
|
||||
font-weight: 600;
|
||||
}
|
||||
.block-card__tools {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.btn--icon {
|
||||
min-width: 2.25rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.block-markdown {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.gallery-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.gallery-row .input {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
.block-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -26,11 +26,15 @@ export const de = {
|
||||
// FEAT-13 (Kelly): Verträge + Einstellungen
|
||||
contracts: 'Verträge',
|
||||
settings: 'Einstellungen',
|
||||
// INBOX-1 (Kelly): Anfragen-Posteingang
|
||||
requests: 'Anfragen',
|
||||
openMenu: 'Menü öffnen',
|
||||
closeMenu: 'Menü schließen',
|
||||
mainNavigation: 'Hauptnavigation',
|
||||
// HELP-1
|
||||
help: 'Hilfe',
|
||||
// WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite
|
||||
website: 'Webseite',
|
||||
},
|
||||
pages: {
|
||||
home: {
|
||||
@@ -45,6 +49,9 @@ export const de = {
|
||||
empty: 'Keine Rennmäuse gefunden.',
|
||||
countLabel: 'Tiere',
|
||||
searchPlaceholder: 'Name suchen …',
|
||||
// BESTAND-FILTER: Kennzeichnung externer (nicht zum eigenen Bestand gehörender) Tiere
|
||||
externalBadge: 'Extern',
|
||||
externalTitle: 'Externes Tier — gehört nicht zum eigenen Bestand, nur für den Stammbaum erfasst.',
|
||||
// Filter-Beschriftungen
|
||||
filters: {
|
||||
title: 'Filter',
|
||||
@@ -54,6 +61,9 @@ export const de = {
|
||||
all: 'Alle',
|
||||
reset: 'Filter zurücksetzen',
|
||||
sortBy: 'Sortieren nach',
|
||||
// BESTAND-FILTER: eigener Bestand (Clan) vs. externe Ahnen (nur Stammbaum)
|
||||
showExternal: 'Externe Ahnen einblenden',
|
||||
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
|
||||
},
|
||||
// Sortier-Optionen
|
||||
sort: {
|
||||
@@ -105,7 +115,10 @@ export const de = {
|
||||
editTitle: 'Rennmaus bearbeiten',
|
||||
none: '— keine Angabe —',
|
||||
genotypeHint:
|
||||
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.',
|
||||
'Optional. Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
saving: 'Speichern …',
|
||||
@@ -217,7 +230,7 @@ export const de = {
|
||||
pickAnimalNoGenotype: 'Für dieses Tier ist kein Genotyp hinterlegt – bitte unten eingeben.',
|
||||
clearAnimal: 'Auswahl entfernen',
|
||||
genotypeLabel: 'Genotyp',
|
||||
genotypeHint: 'Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „?“.',
|
||||
genotypeHint: 'Format z. B. „Aa CC Dd EE GG Pp Spsp rere“. Unbekannte Allele als „-“.',
|
||||
genotypeInvalid: 'Der Genotyp ist ungültig.',
|
||||
genotypePreview: 'Farbschlag',
|
||||
run: 'Probeverpaarung berechnen',
|
||||
@@ -455,6 +468,61 @@ export const de = {
|
||||
saved: 'Gespeichert.',
|
||||
},
|
||||
},
|
||||
// ── INBOX-1 (Kelly): Anfragen-Posteingang ──
|
||||
anfragen: {
|
||||
title: 'Anfragen',
|
||||
empty: 'Keine Anfragen — hole neue E-Mails mit „Anfragen abrufen“.',
|
||||
emptyFiltered: 'Keine Anfragen mit diesem Status.',
|
||||
countText: (n: number) => (n === 1 ? '1 Anfrage' : `${n} Anfragen`),
|
||||
sync: 'Anfragen abrufen',
|
||||
syncing: 'Abrufen …',
|
||||
syncImported: (n: number) =>
|
||||
n === 0 ? 'Keine neuen Anfragen.' : n === 1 ? '1 neue Anfrage abgerufen.' : `${n} neue Anfragen abgerufen.`,
|
||||
mailNotConfigured:
|
||||
'Gmail ist noch nicht eingerichtet — Adresse und App-Passwort folgen in den Einstellungen.',
|
||||
mailAuthFailed:
|
||||
'Gmail-Anmeldung fehlgeschlagen — bitte das App-Passwort neu eintragen.',
|
||||
filterAll: 'Alle',
|
||||
statusLabels: {
|
||||
New: 'Neu',
|
||||
InProgress: 'In Bearbeitung',
|
||||
Assigned: 'Zugeordnet',
|
||||
Answered: 'Beantwortet',
|
||||
Abandoned: 'Verworfen',
|
||||
},
|
||||
// Detailansicht
|
||||
detail: {
|
||||
back: 'Zurück zur Liste',
|
||||
notFound: 'Diese Anfrage wurde nicht gefunden.',
|
||||
from: 'Von',
|
||||
receivedAt: 'Eingegangen',
|
||||
answeredAt: 'Beantwortet am',
|
||||
message: 'Nachricht',
|
||||
noBody: '(kein Text)',
|
||||
noSubject: '(kein Betreff)',
|
||||
// Triage
|
||||
triage: 'Bearbeitung',
|
||||
statusLabel: 'Status',
|
||||
abandon: 'Verwerfen',
|
||||
abandonConfirm: 'Anfrage wirklich verwerfen?',
|
||||
assignContact: 'Abnehmer zuordnen',
|
||||
assignNone: '— kein Abnehmer —',
|
||||
assignedTo: 'Zugeordneter Abnehmer',
|
||||
// Antwort
|
||||
reply: 'Antwort',
|
||||
replyPlaceholder: 'Antwort schreiben oder mit KI entwerfen …',
|
||||
draftButton: 'Antwort entwerfen',
|
||||
drafting: 'Entwurf wird erstellt …',
|
||||
draftHint: 'Die KI erstellt nur einen Entwurf — gesendet wird erst nach deiner Bestätigung.',
|
||||
aiKeyMissing: 'KI-Schlüssel fehlt — der Entwurfs-Assistent wird mit dem Schlüssel aktiviert.',
|
||||
aiUpstreamError: 'Der KI-Dienst hat gerade ein Problem — bitte später erneut versuchen.',
|
||||
send: 'Antwort senden',
|
||||
sending: 'Senden …',
|
||||
sendConfirm: 'Antwort jetzt per E-Mail senden?',
|
||||
sendEmptyBody: 'Bitte zuerst eine Antwort schreiben.',
|
||||
sent: 'Antwort gesendet — die Anfrage ist als „Beantwortet“ markiert.',
|
||||
},
|
||||
},
|
||||
// ── FEAT-2 (Oscar): Kontakte (Contacts — Herkunft/Abnehmer) ──
|
||||
kontakte: {
|
||||
title: 'Kontakte',
|
||||
@@ -576,6 +644,85 @@ export const de = {
|
||||
photoNote:
|
||||
'Fotos sind nicht enthalten — sie liegen als Bilddateien im Datenordner der Anwendung und können von dort gesichert werden.',
|
||||
},
|
||||
// ── WEB-0b (Kevin): CMS-Verwaltung der öffentlichen Webseite ──
|
||||
webseite: {
|
||||
title: 'Webseite',
|
||||
intro:
|
||||
'Hier pflegst du die Inhalte deiner öffentlichen Webseite. Änderungen werden erst beim Veröffentlichen online sichtbar.',
|
||||
empty: 'Es sind noch keine Seiten angelegt.',
|
||||
statusLabel: 'Status',
|
||||
statusDraft: 'Entwurf',
|
||||
statusPublished: 'Veröffentlicht',
|
||||
edit: 'Bearbeiten',
|
||||
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)
|
||||
editor: {
|
||||
pageTitleLabel: 'Seitentitel',
|
||||
seoLabel: 'Beschreibung für Suchmaschinen (SEO)',
|
||||
seoHint: 'Kurzer Text (ca. 1–2 Sätze), der bei Google unter dem Titel erscheint.',
|
||||
save: 'Seite speichern',
|
||||
saved: 'Seite gespeichert.',
|
||||
},
|
||||
// Block-Editor
|
||||
blocks: {
|
||||
sectionTitle: 'Inhaltsblöcke',
|
||||
empty: 'Diese Seite hat noch keine Inhaltsblöcke. Füge unten den ersten hinzu.',
|
||||
addLabel: 'Block hinzufügen:',
|
||||
moveUp: 'Nach oben',
|
||||
moveDown: 'Nach unten',
|
||||
delete: 'Block löschen',
|
||||
confirmDelete: 'Diesen Block wirklich löschen?',
|
||||
save: 'Block speichern',
|
||||
saved: 'Gespeichert.',
|
||||
// Block-Typ-Namen
|
||||
types: {
|
||||
Heading: 'Überschrift',
|
||||
RichText: 'Textabschnitt',
|
||||
Image: 'Bild',
|
||||
Gallery: 'Bildergalerie',
|
||||
ContactInfo: 'Kontaktangaben',
|
||||
AbgabetiereList: 'Abgabetiere (automatisch)',
|
||||
},
|
||||
// Feld-Beschriftungen je Block-Typ
|
||||
fields: {
|
||||
headingText: 'Überschrift-Text',
|
||||
headingLevel: 'Größe',
|
||||
levelMain: 'Hauptüberschrift',
|
||||
levelSub: 'Unterüberschrift',
|
||||
markdown: 'Text',
|
||||
markdownHint:
|
||||
'Einfache Formatierung möglich: **fett**, *kursiv*, Listen mit „- “, Links als [Text](https://…).',
|
||||
imageUrl: 'Bild-Adresse (URL)',
|
||||
imageAlt: 'Bildbeschreibung (für Suchmaschinen & Barrierefreiheit)',
|
||||
galleryImages: 'Bilder',
|
||||
galleryAdd: 'Bild hinzufügen',
|
||||
galleryRemove: 'Entfernen',
|
||||
contactName: 'Name',
|
||||
contactEmail: 'E-Mail',
|
||||
contactPhone: 'Telefon',
|
||||
contactAddress: 'Adresse',
|
||||
abgabeIntro: 'Einleitungstext',
|
||||
abgabeAutoNote:
|
||||
'Die Liste der Abgabetiere wird beim Veröffentlichen automatisch aus deinen aktuellen Abgabetieren erzeugt. Du kannst nur den Einleitungstext bearbeiten.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// ── HELP-1: In-App-Anleitung ──
|
||||
hilfe: {
|
||||
@@ -625,6 +772,10 @@ export const de = {
|
||||
'Schecke × Schecke: Reinerbige Tiere (SpSp) sterben bereits im Mutterleib — etwa ein Viertel weniger Jungtiere.',
|
||||
REX_SEMI_LETHAL:
|
||||
'Rex × Rex: Reinerbige Tiere (ReRe) sind nur eingeschränkt lebensfähig.',
|
||||
SLS_LETHAL:
|
||||
'WP × WP: Reinerbige Tiere (S(l)S(l)) sterben bereits im Mutterleib (Rumpback) — weniger Jungtiere.',
|
||||
SUPERSCHECKE_DEAF:
|
||||
'Schecke × WP: Superschecken (sehr hoher Weißanteil) sind möglich — erhöhtes Taubheitsrisiko.',
|
||||
},
|
||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||
},
|
||||
@@ -656,6 +807,12 @@ export const de = {
|
||||
{ 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
|
||||
|
||||
export type Strings = typeof de
|
||||
|
||||
89
tools/import/conflict-decisions.json
Normal file
89
tools/import/conflict-decisions.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Special field `correctDob` (DD.MM.YYYY): the matched (name + dob) record is a DUPLICATE with a WRONG birthdate — remap its DOB to `correctDob` BEFORE dedup so it merges into the canonical same-named animal. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||
"resolutions": [
|
||||
{
|
||||
"name": "Firefly von den Kleinen Chaoten",
|
||||
"dob": "18.12.2019",
|
||||
"decision": "D-locus = D- (the DD in one source was a typo)",
|
||||
"genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "WildFire von den Kleinen Chaoten",
|
||||
"dob": "05.10.2017",
|
||||
"decision": "P-locus = PP (not P-)",
|
||||
"genotype": "aa c[chm]c[chm] D- Ee gg PP spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Flint von den Kleinen Chaoten",
|
||||
"dob": "23.12.2017",
|
||||
"decision": "death date = 10.05.2021 (the 10.05.2022 variant was a year typo)",
|
||||
"dateOfDeath": "10.05.2021",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||
},
|
||||
{
|
||||
"name": "Molly of Black Forest",
|
||||
"dob": "13.09.2021",
|
||||
"decision": "death date = 03.05.2022 (source 03.05.2021 was a year typo → fell before birth)",
|
||||
"dateOfDeath": "03.05.2022",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D5"
|
||||
},
|
||||
{
|
||||
"name": "Daja of Little Rose",
|
||||
"dob": "16.05.2021",
|
||||
"decision": "keep spsp (present in one source, omitted in the other) — 'presence wins' rule",
|
||||
"genotype": "aa c[chm]c[chm] D- EE Gg P- spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||
},
|
||||
{
|
||||
"name": "Ichika von den Kleinen Chaoten",
|
||||
"dob": "19.04.2020",
|
||||
"decision": "keep ee[f] (the [f] fox-modifier was present in one source, dropped in the other) — 'presence wins' rule",
|
||||
"genotype": "aa CC D- ee[f] Gg pp spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 / Beibehalten-Regel"
|
||||
},
|
||||
{
|
||||
"name": "Zuleika von den Kleinen Chaoten",
|
||||
"dob": "24.10.2015",
|
||||
"decision": "D=DD, E=Ee (one small e), G=Gg (one small g), P=PP (two big P)",
|
||||
"genotype": "aa c[chm]c[h] DD Ee Gg PP spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Milka of LennyLengo",
|
||||
"dob": "09.12.2018",
|
||||
"decision": "C-locus = Cc[h], E-locus = EE",
|
||||
"genotype": "aa Cc[h] dd EE Gg P- Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Silvain von den Kleinen Chaoten",
|
||||
"dob": "27.03.2022",
|
||||
"decision": "E-locus = ee, P-locus = Pp",
|
||||
"genotype": "aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3"
|
||||
},
|
||||
{
|
||||
"name": "Chelsea von den Kleinen Chaoten",
|
||||
"dob": "15.10.2021",
|
||||
"decision": "duplicate with wrong birthdate — same animal as Chelsea *02.04.2021; merge into it",
|
||||
"correctDob": "02.04.2021",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D3 (Chelsea Dublette)"
|
||||
},
|
||||
{
|
||||
"name": "Vestra von den Schlossmäusen",
|
||||
"dob": "08.02.2019",
|
||||
"decision": "D-locus = DD",
|
||||
"genotype": "Aa Cc[chm] DD EE GG PP Spsp [WP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten",
|
||||
"dob": "16.01.2023",
|
||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import glob
|
||||
import shutil
|
||||
import argparse
|
||||
import unicodedata
|
||||
from collections import Counter
|
||||
|
||||
import xlsx_util as xu
|
||||
import genotype as gt
|
||||
@@ -96,6 +97,18 @@ ZUCHT_ALIASES = {
|
||||
"zdkc": "kleinechaote", # "Zucht der kleinen Chaoten" (home cattery shorthand)
|
||||
}
|
||||
|
||||
# A Farbschlag value must NOT contain cattery/line connectors (v.d./von/of/gen.) — when it
|
||||
# does, a parent's NAME has bled into the Farbschlag cell (cross-cell chart read, PEDIGREE-LINK
|
||||
# bug: e.g. "Victoria Welby gen. Welby v.d. Kleinen Chaoten" became a Farbschlag variant and
|
||||
# spawned a phantom conflict). Reject such values so they don't pollute farbschlag/conflicts.
|
||||
_NAME_MARKER = re.compile(r"\bv\.\s?d\.|\bvon\b|\bof\b|\bgen\.", re.IGNORECASE)
|
||||
|
||||
|
||||
def looks_like_animal_name(text):
|
||||
"""True if a candidate Farbschlag cell actually looks like an animal name (has a
|
||||
cattery/line connector). Real Farbschläge are short colour words without these."""
|
||||
return bool(_NAME_MARKER.search(text or ""))
|
||||
|
||||
|
||||
def split_name_zucht(raw):
|
||||
"""'Luna [ZdkC]' -> ('Luna','ZdkC'); 'Pikachu of Black Forest' ->
|
||||
@@ -145,6 +158,10 @@ def parse_detail(text):
|
||||
tail = text[dob.end():]
|
||||
tail = re.sub(r"^\s*/?\+?\s?\d[\d.]*", "", tail) # drop any /+death remnant
|
||||
tail = tail.lstrip(" ,").strip()
|
||||
# FIX-4 (Skarlett): strip trailing "/ +YEAR" death-year artifacts leaked from compact
|
||||
# chart cells (e.g. "… rere / +2018"). The DEATH regex still captures the year from
|
||||
# the full cell text, so it appears as a death-date conflict — not a genotype conflict.
|
||||
tail = re.sub(r"\s*/\s*\+\d{4}\s*$", "", tail).strip()
|
||||
if gt.looks_like_genotype(tail):
|
||||
geno = tail
|
||||
return (dob.group(1) if dob else "",
|
||||
@@ -159,6 +176,7 @@ def extract_stammbaum(path):
|
||||
ss = xu.shared_strings(z)
|
||||
sheets = xu.sheet_paths(z)
|
||||
cells = xu.read_cells(z, sheets[0], ss)
|
||||
fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female)
|
||||
|
||||
# group cells by column for block reconstruction
|
||||
by_col = {}
|
||||
@@ -197,6 +215,11 @@ def extract_stammbaum(path):
|
||||
farbschlag = ""
|
||||
geno = geno0
|
||||
breeder = ""
|
||||
# BAND-AWARE (Julian-confirmed): early bands (gen 0-1, cols B/E/H) are 5-cell blocks
|
||||
# WITH a Farbschlag cell; deep bands (gen >= 2, cols K/N/Q...) are 3-cell blocks
|
||||
# (Name/DOB/Genotype) with NO Farbschlag — colour is derived from the genotype. So in
|
||||
# deep bands we must NOT grab the next block's name or a stray health note as Farbschlag.
|
||||
deep_band = gen_of(c) >= 2
|
||||
for rr in range(r + 1, r + 4):
|
||||
cell = cells.get((c, rr))
|
||||
if not cell:
|
||||
@@ -207,7 +230,8 @@ def extract_stammbaum(path):
|
||||
elif re.search(r"\b(Zucht|Privatzucht)\b", cell) or cell.startswith("("):
|
||||
breeder = cell
|
||||
used.add((c, rr))
|
||||
elif not farbschlag and not re.match(r"^\*?\s?\d", cell):
|
||||
elif not deep_band and not farbschlag and not re.match(r"^\*?\s?\d", cell) \
|
||||
and not looks_like_animal_name(cell):
|
||||
farbschlag = cell
|
||||
used.add((c, rr))
|
||||
used.add((c, r))
|
||||
@@ -224,9 +248,11 @@ def extract_stammbaum(path):
|
||||
"nameVariants": [],
|
||||
"dob": dob,
|
||||
"death": death,
|
||||
"gender": None,
|
||||
"gender": fillsex.get((c, r)), # box colour: blue=male, white=female
|
||||
"farbschlag": farbschlag,
|
||||
"genotype": genodict,
|
||||
"deaf": genodict.get("deaf"),
|
||||
"tags": genodict.get("tags", []),
|
||||
"breeder": breeder,
|
||||
"zucht": zraw,
|
||||
"parentRefs": [],
|
||||
@@ -251,7 +277,8 @@ def extract_stammbaum(path):
|
||||
animals.append({
|
||||
"id": None, "name": part, "nameVariants": [],
|
||||
"dob": "", "death": "", "gender": None, "farbschlag": "",
|
||||
"genotype": gt.parse(""), "breeder": "", "zucht": zraw,
|
||||
"genotype": gt.parse(""), "deaf": None, "tags": [],
|
||||
"breeder": "", "zucht": zraw,
|
||||
"parentRefs": [], "photos": [], "sourceFiles": [fname],
|
||||
"_gen": gen_of(c), "_col": c, "_row": r, "_file": fname,
|
||||
"_zucht": norm_zucht(zraw),
|
||||
@@ -472,10 +499,64 @@ def _to_int(s):
|
||||
|
||||
|
||||
# ------------------------------------------------------------- stage 2: dedup
|
||||
def _geno_key(genodict):
|
||||
"""Canonical, order-independent key of a genotype's mapped loci — used for conflict
|
||||
detection so Uw==G (and allele ordering) no longer count as a conflict."""
|
||||
m = genodict.get("mapped8locus", {})
|
||||
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):
|
||||
"""Merge by normalise(call-name)+DOB, with the canonical Zucht as
|
||||
DISCRIMINATOR (Julian: same name+DOB but different Zucht = different
|
||||
animal). Returns (merged, conflicts, orphans, zucht_splits)."""
|
||||
DISCRIMINATOR (Julian: same name+DOB+Zucht = same animal; different Zucht =
|
||||
different animal). Returns (merged, conflicts, orphans, zucht_splits)."""
|
||||
groups = {}
|
||||
orphans = []
|
||||
for a in animals:
|
||||
@@ -521,29 +602,45 @@ def dedup(animals):
|
||||
photos = list(base["photos"])
|
||||
parent_refs = list(base["parentRefs"])
|
||||
genos = set()
|
||||
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()
|
||||
deaths = set()
|
||||
deaf_seen = set()
|
||||
tags_set = set()
|
||||
genders = []
|
||||
for a in grp:
|
||||
variants.add(a["name"])
|
||||
files.update(a["sourceFiles"])
|
||||
photos.extend(a["photos"])
|
||||
parent_refs.extend(a["parentRefs"])
|
||||
if a["genotype"]["rawGenotype"]:
|
||||
if a.get("gender"):
|
||||
genders.append(a["gender"])
|
||||
if a["genotype"]["mapped8locus"]:
|
||||
genos.add(a["genotype"]["rawGenotype"])
|
||||
geno_keys.add(_geno_key(a["genotype"]))
|
||||
mapped_variants.append(a["genotype"]["mapped8locus"])
|
||||
if a["farbschlag"]:
|
||||
farb.add(a["farbschlag"])
|
||||
if a["death"]:
|
||||
deaths.add(norm_dob(a["death"]))
|
||||
# pick the richest genotype (most mapped loci, then longest raw)
|
||||
if a.get("deaf") is not None:
|
||||
deaf_seen.add(a["deaf"])
|
||||
tags_set.update(a.get("tags", []))
|
||||
# pick the richest genotype: most mapped loci, then fewest unknowns ('?' alleles = specific
|
||||
# wins, FIX-2), then longest raw string as final tiebreaker.
|
||||
def _specificity(gd):
|
||||
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
|
||||
best = max((a["genotype"] for a in grp),
|
||||
key=lambda gd: (len(gd["mapped8locus"]), len(gd["rawGenotype"])))
|
||||
key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
|
||||
out = {
|
||||
"id": slug(base["name"], base["dob"]),
|
||||
"name": base["name"],
|
||||
"nameVariants": sorted(v for v in variants if v),
|
||||
"dob": norm_dob(base["dob"]),
|
||||
"death": sorted(deaths)[0] if deaths else "",
|
||||
"gender": None,
|
||||
# box-colour sex (blue=male, white=female): majority across mentions, else None.
|
||||
"gender": Counter(genders).most_common(1)[0][0] if genders else None,
|
||||
"farbschlag": sorted(farb)[0] if farb else "",
|
||||
"farbschlagVariants": sorted(farb),
|
||||
"genotype": best,
|
||||
@@ -554,13 +651,17 @@ def dedup(animals):
|
||||
"photos": sorted(set(photos)),
|
||||
"sourceFiles": sorted(files),
|
||||
"mentions": len(grp),
|
||||
# GEN-3b: hearing/deaf phenotype flag (deaf wins if any mention says so) + tags.
|
||||
"deaf": (True if True in deaf_seen else (False if False in deaf_seen else None)),
|
||||
"tags": sorted(tags_set),
|
||||
# FEAT-8c: machine-readable quarantine marker so the API loader can skip
|
||||
# conflicting records without parsing the German review report.
|
||||
"conflict": False,
|
||||
}
|
||||
merged.append(out)
|
||||
# conflict: same animal, disagreeing genotype or farbschlag or death
|
||||
if len(genos) > 1 or len(farb) > 1 or len(deaths) > 1:
|
||||
# conflict: same animal, GENUINELY disagreeing genotype (presence-vs-absence is NOT a
|
||||
# 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
|
||||
conflicts.append({
|
||||
"id": out["id"], "name": base["name"], "dob": out["dob"],
|
||||
@@ -790,6 +891,90 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
def apply_dob_remaps(raw_animals, path):
|
||||
"""PRE-dedup: a conflict-decision carrying `correctDob` marks a record as a DUPLICATE with a
|
||||
wrong birthdate — remap that raw record's DOB to correctDob so dedup MERGES it into the
|
||||
canonical same-named animal (e.g. Chelsea *15.10.2021 -> *02.04.2021). Match =
|
||||
canon_pair(name)+(dob) with same Zucht-aware logic as apply_conflict_decisions (see there).
|
||||
Tolerates a missing/garbled file. Returns the remap count.
|
||||
Must run BEFORE dedup (it changes the dedup identity). (god/HUMANQUESTION D — Dubletten.)"""
|
||||
remaps_full = {} # (nameCanon, zuchtCanon, dob) -> correctDob — decision carries Zucht
|
||||
remaps_name = {} # (nameCanon, dob) -> correctDob — no Zucht in decision
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for r in (json.load(fh).get("resolutions") or []):
|
||||
if r.get("correctDob"):
|
||||
nc, zc = canon_pair(r.get("name", ""))
|
||||
dob = norm_dob(r.get("dob", ""))
|
||||
if zc:
|
||||
remaps_full[(nc, zc, dob)] = r["correctDob"]
|
||||
else:
|
||||
remaps_name[(nc, dob)] = r["correctDob"]
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
if not remaps_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
|
||||
|
||||
resolved = 0
|
||||
for a in merged:
|
||||
nc, zc = canon_pair(a["name"])
|
||||
dob = norm_dob(a["dob"])
|
||||
d = decisions_full.get((nc, zc, dob)) or decisions_name.get((nc, dob))
|
||||
if not d:
|
||||
continue
|
||||
a["resolvedByDecision"] = True
|
||||
if d.get("genotype"):
|
||||
a["genotype"] = gt.parse(d["genotype"])
|
||||
if d.get("farbschlag"):
|
||||
a["farbschlag"] = d["farbschlag"]
|
||||
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||
if d.get("dateOfDeath"): # D5 death-date resolutions
|
||||
a["death"] = norm_dob(d["dateOfDeath"])
|
||||
if a.get("conflict"):
|
||||
a["conflict"] = False
|
||||
conflicts[:] = [c for c in conflicts if c.get("id") != a["id"]]
|
||||
resolved += 1
|
||||
return resolved
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
@@ -809,7 +994,9 @@ def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
raw_animals = []
|
||||
files = sorted(glob.glob(os.path.join(args.stammbaeume, "*.xlsx")))
|
||||
# Skip Excel lock/owner files ("~$...") that appear while a workbook is open.
|
||||
files = sorted(f for f in glob.glob(os.path.join(args.stammbaeume, "*.xlsx"))
|
||||
if not os.path.basename(f).startswith("~$"))
|
||||
print(f"Stammbaum-Dateien: {len(files)}")
|
||||
for path in files:
|
||||
got = extract_stammbaum(path)
|
||||
@@ -821,7 +1008,10 @@ def main():
|
||||
litters = extract_wurfchronik(args.wurfchronik)
|
||||
print(f"Wurfchronik: {len(litters)} Würfe")
|
||||
|
||||
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)
|
||||
match_stats = match_litters(merged, litters)
|
||||
photo_count = sum(len(a["photos"]) for a in merged)
|
||||
|
||||
@@ -838,7 +1028,8 @@ def main():
|
||||
zucht_splits, match_stats)
|
||||
|
||||
print(f"\nRoh: {len(raw_animals)} → eindeutig: {len(merged)} "
|
||||
f"| Konflikte: {len(conflicts)} | Zucht-Splits: {len(zucht_splits)} "
|
||||
f"| Konflikte: {len(conflicts)} | per Entscheidung gelöst: {resolved_by_decision} "
|
||||
f"| DOB-Remaps: {dob_remaps} | Zucht-Splits: {len(zucht_splits)} "
|
||||
f"| Orphans: {len(orphans)} | Fotos: {photo_count}")
|
||||
print(f"Wurf-Verknüpfung: {match_stats['parents']} (Datum+Eltern), "
|
||||
f"{match_stats['dateOnly']} (nur Datum), {match_stats['ambiguous']} mehrdeutig "
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
"""Parse the breeder's free-text genotype notation into our frozen 8-locus
|
||||
contract while losing nothing (FEAT-8b ruling from god):
|
||||
"""Parse the breeder's free-text genotype notation into our locus model while
|
||||
losing nothing (FEAT-8b + GEN-3b normalization, per hive/agents/god/GENETIK-notation.md):
|
||||
|
||||
- mapped8locus : {locus: [allele1, allele2]} for A C D E G P Sp Re
|
||||
- rawGenotype : the verbatim source string
|
||||
- unmappedTokens: tokens we couldn't map (Uw/Sls/Dea, markers like WFNZ/WP/DP, …)
|
||||
- mapped8locus : {locus: [allele1, allele2]} for A C D E G P Sp Re (+ Sls when present)
|
||||
- rawGenotype : the verbatim source string
|
||||
- unmappedTokens: tokens we still couldn't place
|
||||
- deaf : True (dea/taub) | False (Dea/hörend) | None (not stated) — phenotype FLAG, not a locus
|
||||
- tags : provenance/breeding markers (WFNZ/RV/GV/DP/extern …) — never genotype
|
||||
|
||||
GEN-3b normalizations (wife + research confirmed):
|
||||
- Uw/uw == G/g (international vs German notation for the SAME locus) -> aliased to G/g.
|
||||
- Sls/WP is a SECOND spotting locus (S(l)s(l) = WP/Minimalschecke het). WP -> Sls het.
|
||||
- Dea/dea/taub -> hearing/deaf flag (written after spsp), NOT a Punnett locus.
|
||||
- WFNZ/RV/GV -> provenance/breeding tags, NOT genotype, NOT conflict-bearing.
|
||||
|
||||
Conventions in the source data:
|
||||
- allele superscripts are bracketed: c[chm] -> c^chm, c[h] -> c^h, e[f] -> e^f
|
||||
- a single '-' for the second allele means "unknown" -> mapped to '?'
|
||||
(frozen-contract wildcard; assumption pending the wife's confirmation)
|
||||
"""
|
||||
import re
|
||||
|
||||
LOCI = ["A", "C", "D", "E", "G", "P", "Sp", "Re"]
|
||||
|
||||
# locus -> regex that matches that locus's token (longest alternatives first)
|
||||
_LOCUS_TOKEN = {
|
||||
"Sp": re.compile(r"^(Sp|sp)(Sp|sp|-)?$"),
|
||||
"Re": re.compile(r"^(Re|re)(Re|re|-)?$"),
|
||||
@@ -25,12 +31,7 @@ _LOCUS_TOKEN = {
|
||||
"G": re.compile(r"^(G|g)(G|g|-)?$"),
|
||||
"P": re.compile(r"^(P|p)(P|p|-)?$"),
|
||||
}
|
||||
# loci our model does NOT have but the data uses
|
||||
_KNOWN_UNMAPPED = re.compile(r"^(Uw|uw)(\[d\])?(Uw|uw)?(\[d\])?$|^(Sls|sls|Dea|dea)$", re.I)
|
||||
_MARKER = re.compile(r"^\[?(WFNZ|WP|DP|GV|RV)\]?$|^\((taub|hörend|hoerend|RV|GV|extern[^)]*)\)$", re.I)
|
||||
|
||||
|
||||
# one allele unit per locus (longest-match alternatives first); '-' = unknown
|
||||
_ALLELE_UNIT = {
|
||||
"Sp": re.compile(r"Sp|sp|-"),
|
||||
"Re": re.compile(r"Re|re|-"),
|
||||
@@ -42,10 +43,41 @@ _ALLELE_UNIT = {
|
||||
"P": re.compile(r"[Pp]|-"),
|
||||
}
|
||||
|
||||
# Provenance/breeding tags (never genotype): Wildfangnachzucht, Rückverpaarung,
|
||||
# Geschwisterverpaarung, DarkPatch, external origin.
|
||||
_TAG = re.compile(r"^\[?(WFNZ|RV|GV|DP)\]?$|^\((RV|GV|extern[^)]*)\)$", re.I)
|
||||
|
||||
|
||||
def _rewrite_uw(token):
|
||||
"""Uw/uw notation -> G/g (same locus). 'Uwuw[d]' -> 'Gg', 'UwUw' -> 'GG', 'uw[d]uw[d]' -> 'gg'."""
|
||||
if "uw" not in token.lower():
|
||||
return token
|
||||
return token.replace("uw[d]", "g").replace("Uw", "G").replace("uw", "g")
|
||||
|
||||
|
||||
def _sls_alleles(token):
|
||||
"""Sls (second spotting locus) alleles, or None. WP == Sls het (Minimalschecke);
|
||||
S(l)S(l) homozygous = lethal. Allele symbols: 'Sl' / 'sl'."""
|
||||
n = token.strip("[]").replace("(l)", "l").replace("(L)", "l")
|
||||
if n in ("WP", "Sls"):
|
||||
return ["Sl", "sl"] # heterozygous (WP phenotype)
|
||||
if n.lower() == "sls":
|
||||
return ["sl", "sl"] # wild-type (no extra spotting)
|
||||
units = re.findall(r"Sl|sl", n)
|
||||
return units if len(units) == 2 else None
|
||||
|
||||
|
||||
def _deaf_value(token):
|
||||
"""dea/taub -> True (deaf); Dea/hörend -> False (hearing); else None. Case-sensitive for Dea/dea."""
|
||||
t = token.strip("()[]")
|
||||
if t == "dea" or t.lower() == "taub":
|
||||
return True
|
||||
if t == "Dea" or t.lower() in ("hörend", "hoerend"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _alleles_for(locus, token):
|
||||
"""Extract the (allele1, allele2) pair from a single locus token, handling
|
||||
two-letter alleles (Sp/Re) and bracketed superscripts (c[chm] -> c^chm)."""
|
||||
pat = _ALLELE_UNIT.get(locus)
|
||||
units = pat.findall(token) if pat else re.findall(r"[A-Za-z](?:\[[a-z]+\])?|-", token)
|
||||
alleles = []
|
||||
@@ -55,7 +87,6 @@ def _alleles_for(locus, token):
|
||||
else:
|
||||
m = re.match(r"([A-Za-z]+)\[([a-z\-]+)\]", u)
|
||||
if m:
|
||||
# [-] = sub-allele unknown -> keep the base letter only
|
||||
alleles.append(m.group(1) if m.group(2) == "-" else f"{m.group(1)}^{m.group(2)}")
|
||||
else:
|
||||
alleles.append(u)
|
||||
@@ -65,37 +96,60 @@ def _alleles_for(locus, token):
|
||||
|
||||
|
||||
def parse(raw):
|
||||
"""raw: a genotype string (may include trailing free text/markers).
|
||||
"""raw: a genotype string (may include trailing markers/flags).
|
||||
|
||||
Returns dict {mapped8locus, rawGenotype, unmappedTokens}.
|
||||
Returns {mapped8locus, rawGenotype, unmappedTokens, deaf, tags}.
|
||||
"""
|
||||
raw = (raw or "").strip()
|
||||
mapped = {}
|
||||
unmapped = []
|
||||
# tokenise on whitespace; keep order
|
||||
tags = []
|
||||
deaf = None
|
||||
|
||||
for tok in raw.split():
|
||||
t = tok.strip().rstrip(",")
|
||||
if not t:
|
||||
continue
|
||||
|
||||
# GEN-3b: Uw/uw is an alias of the G locus — rewrite before matching.
|
||||
t = _rewrite_uw(t)
|
||||
|
||||
# 8 standard loci
|
||||
matched = False
|
||||
for locus in LOCI:
|
||||
pat = _LOCUS_TOKEN.get(locus)
|
||||
if pat and pat.match(t):
|
||||
if locus not in mapped: # first occurrence wins
|
||||
mapped[locus] = _alleles_for(locus, t)
|
||||
mapped.setdefault(locus, _alleles_for(locus, t)) # first occurrence wins
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
continue
|
||||
if _KNOWN_UNMAPPED.match(t) or _MARKER.match(t):
|
||||
unmapped.append(t)
|
||||
else:
|
||||
# anything else (stray notes, malformed tokens) -> unmapped, nothing lost
|
||||
unmapped.append(t)
|
||||
|
||||
# Sls (second spotting locus); WP is its heterozygous phenotype
|
||||
sls = _sls_alleles(t)
|
||||
if sls is not None:
|
||||
mapped.setdefault("Sls", sls)
|
||||
continue
|
||||
|
||||
# deafness flag (after spsp): dea/taub vs Dea/hörend
|
||||
d = _deaf_value(t)
|
||||
if d is not None:
|
||||
deaf = d
|
||||
continue
|
||||
|
||||
# provenance/breeding tags
|
||||
if _TAG.match(t):
|
||||
tags.append(re.sub(r"[()\[\]]", "", t).upper())
|
||||
continue
|
||||
|
||||
unmapped.append(t)
|
||||
|
||||
return {
|
||||
"mapped8locus": mapped,
|
||||
"rawGenotype": raw,
|
||||
"unmappedTokens": unmapped,
|
||||
"deaf": deaf,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +157,7 @@ def looks_like_genotype(text):
|
||||
"""Heuristic: does this cell text contain >=3 recognisable locus tokens?"""
|
||||
n = 0
|
||||
for tok in text.split():
|
||||
t = tok.rstrip(",")
|
||||
t = _rewrite_uw(tok.rstrip(","))
|
||||
if any(p.match(t) for p in _LOCUS_TOKEN.values()):
|
||||
n += 1
|
||||
return n >= 3
|
||||
|
||||
@@ -4,15 +4,15 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
||||
|
||||
## Überblick
|
||||
|
||||
- Rohe Tier-Einträge aus den Stammbäumen: **889**
|
||||
- Nach Zusammenführung (eindeutige Tiere): **574**
|
||||
- davon mit Geburtsdatum: 279
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 146
|
||||
- Konflikte zur Klärung: **32**
|
||||
- Rohe Tier-Einträge aus den Stammbäumen: **950**
|
||||
- Nach Zusammenführung (eindeutige Tiere): **621**
|
||||
- davon mit Geburtsdatum: 326
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||
- Konflikte zur Klärung: **5**
|
||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
||||
- Fotos zugeordnet: **123**
|
||||
- Fotos zugeordnet: **137**
|
||||
- Würfe aus der Wurfchronik: **752**
|
||||
- Tiere mit Wurf verknüpft: **135** (davon über Geburtsdatum **und** Eltern: 95, nur über Geburtsdatum: 40; mehrdeutig: 9)
|
||||
- Tiere mit Wurf verknüpft: **159** (davon über Geburtsdatum **und** Eltern: 110, nur über Geburtsdatum: 49; mehrdeutig: 10)
|
||||
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
||||
|
||||
## Zusammenführungs-Schlüssel
|
||||
@@ -25,38 +25,11 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
||||
|
||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||
|---|---|---|---|---|---|
|
||||
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
||||
| ZoneFire | 07.12.2020 | Aa c[chm]c[chm] D- Ee Gg P- Spsp | CP-Agouti Kragenschecke // Kalea von den Kleinen Chaoten | — | Stammbaum von Akio 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 | Roswitha von den Kleinen Chaoten | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
||||
| Roswitha von den Kleinen Chaoten | 10.09.2018 | aa CC D- ee[f] Gg P- spsp // aa CC D- ee[f] Uwuw[d] P- spsp | — | 05.08.2021 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
||||
| Firefly von den Kleinen Chaoten | 18.12.2019 | /+, Aa c[chm]c[chm] D- Ee Gg PP Spsp // Aa c[chm]c[chm] DD Ee Gg PP Spsp | — | 2024 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Valentino Firehearts Kids |
|
||||
| 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 |
|
||||
| WildFire von den Kleinen Chaoten | 05.10.2017 | aa c[chm]c[chm] D- Ee gg P- spsp // aa c[chm]c[chm] D- Ee gg PP spsp | — | — | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, 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 |
|
||||
| Silenos gen. Adonis v.d. Kleinen Chaoten | 11.10.2015 | aa Cc[chm] D- Ee Gg PP spsp // aa Cc[chm] D- Ee Uwuw[d] PP spsp | — | 18.07.2019 | Stammbaum von Akio Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Watarus Kids |
|
||||
| Bruno of Black Forest | 01.06.2022 | aa C- dd Ee Gg P- spsp | Blau // Mystique of Black Forest | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||
| 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 |
|
||||
| Hedwig of BGB | 30.10.2019 | aa CC DD E- G- P- Spsp WP // aa CC DD E- G- P- Spsp WP DP (hörend) | — | 30.08.2023 | Stammbaum von Alberto Kids, Stammbaum von Fire 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 |
|
||||
| Pitari gen. Piti von den Kleinen Chaoten | 16.05.2021 | Aa CC dd ee Gg P- Spsp DP // Aa CC dd ee Gg P- Spsp [DP] | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||
| Brandon Stark von den Kleinen Chaoten | 13.12.2017 | aa Cc[chm] D- Ee Gg P- spsp // aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
|
||||
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
|
||||
| Little Hero of Black Forest | 22.02.2018 | AA CC DD EE GG PP [WFNZ] // AA CC DD EE GG PP spsp [WFNZ] | — | 18.06.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids, Stammbaum von Valentino Firehearts Kids |
|
||||
| Molly of Black Forest | 13.09.2021 | /+, Aa Cc[chm] D- Ee gg P- spsp // Aa Cc[chm] Dd Ee gg Pp spsp | — | 03.05.2021 | Stammbaum von Alberto Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
||||
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | Daja of Little Rose | 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 // Velvet von den Kleinen Chaoten | — | 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 |
|
||||
| Trogir von den Kleinen Chaoten | 21.03.2022 | Aa CC DD EE GG pp Spsp | Gold Ansatzschecke // Mahima von den Kleinen Chaoten | 29.05.2024 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
||||
| Chayton v.d. Kleinen Chaoten (extern SC) | 04.02.2022 | aa Cc[-] D- e[f]e[f] Gg Pp spsp | Orangeschimmel, hell // Victoria Welby gen. Welby v.d. Kleinen Chaoten | 30.04.2024 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, 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] | Belica gen. Emi von den Kleinen Chaoten | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
||||
| Chelsea von den Kleinen Chaoten | 02.04.2021 | /+, Aa CC Dd ee gg Pp spsp // Aa CC Dd ee gg Pp spsp | — | — | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Valentino Firehearts Kids |
|
||||
| Chesnut | 13.11.2019 | aa C- D- ee[f] GG PP spsp | Kohlfuchsschimmel // Tennessee von den Kleinen Chaoten | 22.11.2023 | Stammbaum von Kentucky |
|
||||
| Ethan von den Kleinen Chaoten | 09.07.2020 | Aa Cc[chm] D- ee[f] Gg Pp Spsp | Ichika von den Kleinen Chaoten // Orangeschimmel, hell Kragenschecke | 30.07.2024 | Stammbaum von Kentucky, Stammbaum von Watarus Kids |
|
||||
| Quied Soldier of Black Forest | 07.06.2018 | /+, Aa C- D- ee[f] GG Pp Spsp [DP] // Aa C- D- ee[f] GG Pp Spsp DP | Hoshi von den Kleinen Chaoten | — | Stammbaum von Kentucky |
|
||||
| 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 |
|
||||
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
||||
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||
| 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 |
|
||||
|
||||
## Mehrdeutige / unvollständige Einträge
|
||||
|
||||
@@ -105,7 +78,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
||||
|
||||
## Wahrscheinliche Zuordnungen unvollständiger Einträge
|
||||
|
||||
38 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
|
||||
39 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
|
||||
|
||||
- „Oscar of Black Forest“ → Oscar of Black Forest (*12.06.2019)
|
||||
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
|
||||
@@ -114,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)
|
||||
- „Charly of Golden Lights“ → Charly of Golden Lights (*05.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)
|
||||
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
|
||||
- „Scarlett of Samsimar“ → Scarlett of Samsimar (*05.09.2018)
|
||||
@@ -137,6 +110,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
||||
- „Zadar from Zeko i ptica, Croatia“ → Zadar from Zeko i ptica, Croatia (*12.04.2019)
|
||||
- „Living Force's Vally“ → Living Force's Vally (*01.11.2014)
|
||||
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
|
||||
- „Hanse Renner's Poseidon“ → Hanse Renner's Poseidon (*14.08.2014)
|
||||
- „Oscar of Black Forest“ → Oscar of Black Forest (*12.06.2019)
|
||||
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
|
||||
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
|
||||
@@ -152,29 +126,21 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
||||
|
||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||
|---|---|---|
|
||||
| `[DP]` | 15 | Marker (Dunkelpigment?) |
|
||||
| `[WFNZ]` | 13 | Marker |
|
||||
| `DP` | 9 | Marker |
|
||||
| `/+` | 8 | ? |
|
||||
| `WP` | 7 | Marker |
|
||||
| `Uwuw[d]` | 4 | 9. Locus Uw (nicht im Modell) |
|
||||
| `/+` | 6 | ? |
|
||||
| `-g` | 2 | ? |
|
||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||
| `[WP]` | 2 | Marker |
|
||||
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
|
||||
| `Cc[]` | 1 | ? |
|
||||
| `-psp` | 1 | ? |
|
||||
| `G(G)` | 1 | ? |
|
||||
| `UwUw` | 1 | 9. Locus Uw |
|
||||
| `uw[d]uw[d]` | 1 | ? |
|
||||
| `[DP` | 1 | ? |
|
||||
| `/` | 1 | ? |
|
||||
| `+2018` | 1 | ? |
|
||||
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
|
||||
| `c[chm]chm]` | 1 | ? |
|
||||
| `Dea/dea]` | 1 | ? |
|
||||
| `DD-Tumor` | 1 | ? |
|
||||
| `bei` | 1 | ? |
|
||||
| `Geschwistern` | 1 | ? |
|
||||
| `C-D-` | 1 | ? |
|
||||
| `Sls` | 1 | ? |
|
||||
| `(hörend)` | 1 | ? |
|
||||
| `-DD` | 1 | ? |
|
||||
|
||||
## Wurfchronik — Datenqualitäts-Hinweise
|
||||
|
||||
286
tools/import/test_extract.py
Normal file
286
tools/import/test_extract.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Zero-dep tests for extract.py band-aware Farbschlag + name-bleed guard.
|
||||
|
||||
Run: python test_extract.py (exit 0 = all pass)
|
||||
Covers (PEDIGREE-LINK / Julian-confirmed): deep pedigree bands (gen >= 2, cols K/N/Q...)
|
||||
are Name/DOB/Genotype ONLY — no Farbschlag cell — so a stray health note or the next
|
||||
block's name must NOT be captured as Farbschlag; early bands (gen 0-1) keep their real
|
||||
Farbschlag. Plus the looks_like_animal_name guard (a parent name must not be a Farbschlag).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import tempfile
|
||||
import extract as e
|
||||
|
||||
failed = 0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
global failed
|
||||
print(("ok: " if cond else "FAIL: ") + name)
|
||||
if not cond:
|
||||
failed += 1
|
||||
|
||||
|
||||
def _cell(ref, text):
|
||||
return f'<c r="{ref}" t="inlineStr"><is><t>{text}</t></is></c>'
|
||||
|
||||
|
||||
def _make_xlsx(path, cells):
|
||||
"""cells: {(colLetter+row): text}. Build a minimal single-sheet xlsx (no styles)."""
|
||||
rows = {}
|
||||
for ref, text in cells.items():
|
||||
r = int("".join(ch for ch in ref if ch.isdigit()))
|
||||
rows.setdefault(r, []).append(_cell(ref, text))
|
||||
body = "".join(f'<row r="{r}">{"".join(cs)}</row>' for r, cs in sorted(rows.items()))
|
||||
sheet = ('<?xml version="1.0"?><worksheet xmlns="http://x"><sheetData>'
|
||||
+ body + "</sheetData></worksheet>")
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("xl/worksheets/sheet1.xml", sheet)
|
||||
|
||||
|
||||
# --- band-aware Farbschlag ---
|
||||
# col E = gen 0 (early, HAS Farbschlag); col K = col 11 = gen 2 (deep, NO Farbschlag).
|
||||
tmp = os.path.join(tempfile.gettempdir(), "bandtest.xlsx")
|
||||
_make_xlsx(tmp, {
|
||||
# early band (E): Name / *DOB / Farbschlag / Genotype
|
||||
"E10": "Chesnut",
|
||||
"E11": "*13.11.2019",
|
||||
"E12": "Kohlfuchsschimmel",
|
||||
"E13": "aa CC DD ee GG PP spsp rere",
|
||||
# deep band (K): Name / *DOB / Genotype / stray NOTE (must NOT become Farbschlag)
|
||||
"K10": "DeepAnimal",
|
||||
"K11": "*01.01.2020",
|
||||
"K12": "aa CC DD EE GG PP spsp rere",
|
||||
"K13": "DD-Tumor",
|
||||
})
|
||||
try:
|
||||
animals = e.extract_stammbaum(tmp)
|
||||
by_name = {a["name"]: a for a in animals}
|
||||
check("early band keeps real Farbschlag",
|
||||
by_name.get("Chesnut", {}).get("farbschlag") == "Kohlfuchsschimmel")
|
||||
check("deep band has NO Farbschlag (note not grabbed)",
|
||||
by_name.get("DeepAnimal", {}).get("farbschlag") == "")
|
||||
check("deep-band animal still parsed (Name/DOB/Genotype)",
|
||||
"DeepAnimal" in by_name and by_name["DeepAnimal"]["dob"].startswith("01.01"))
|
||||
finally:
|
||||
try: os.remove(tmp)
|
||||
except OSError: pass
|
||||
|
||||
gen = e.gen_of
|
||||
check("gen_of: early bands < 2 (E,H)", gen(5) < 2 and gen(8) < 2)
|
||||
check("gen_of: deep bands >= 2 (K,N,Q)", gen(11) >= 2 and gen(14) >= 2)
|
||||
|
||||
# --- conflict-decisions consumption (HUMANQUESTION D / C6) ---
|
||||
dec_path = os.path.join(tempfile.gettempdir(), "conflict-decisions-test.json")
|
||||
import json as _json
|
||||
_json.dump({"resolutions": [
|
||||
{"name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
|
||||
"decision": "D-locus = D-", "genotype": "Aa c[chm]c[chm] D- Ee Gg PP Spsp",
|
||||
"source": "test"},
|
||||
{"name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
|
||||
"decision": "Todesdatum 10.05.2021 (2022 war Tippfehler)", "dateOfDeath": "10.05.2021",
|
||||
"source": "test"},
|
||||
]}, open(dec_path, "w", encoding="utf-8"))
|
||||
merged = [
|
||||
{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019",
|
||||
"conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"D": ["D", "D"]}, "rawGenotype": "DD", "unmappedTokens": []}},
|
||||
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017",
|
||||
"conflict": True, "farbschlag": "", "death": "10.05.2022",
|
||||
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts = [{"id": "x1", "name": "Firefly von den Kleinen Chaoten", "dob": "18.12.2019"},
|
||||
{"id": "x2", "name": "Flint von den Kleinen Chaoten", "dob": "23.12.2017"}]
|
||||
n = e.apply_conflict_decisions(merged, conflicts, dec_path)
|
||||
check("decision un-quarantines (conflict cleared)", merged[0]["conflict"] is False)
|
||||
check("decision marks resolvedByDecision", merged[0].get("resolvedByDecision") is True)
|
||||
check("decision genotype is authoritative (D- not DD)", merged[0]["genotype"]["mapped8locus"]["D"] == ["D", "?"])
|
||||
check("decision dateOfDeath is authoritative (D5)", merged[1]["death"] == "10.05.2021")
|
||||
check("decision removes both entries from conflicts list", conflicts == [])
|
||||
check("apply_conflict_decisions returns resolved count", n == 2)
|
||||
check("missing decisions file tolerated (returns 0)",
|
||||
e.apply_conflict_decisions([], [], os.path.join(tempfile.gettempdir(), "does-not-exist.json")) == 0)
|
||||
|
||||
# FIX-1: decision matching uses canon_pair identity -> 'von den' decision matches 'v.d.' record
|
||||
dec_vd = os.path.join(tempfile.gettempdir(), "decisions-vd.json")
|
||||
_json.dump({"resolutions": [
|
||||
{"name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # written with 'von den'
|
||||
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||
merged_vd = [
|
||||
{"id": "vw", "name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # record has 'v.d.'
|
||||
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_vd = [{"id": "vw"}]
|
||||
n_vd = e.apply_conflict_decisions(merged_vd, conflicts_vd, dec_vd)
|
||||
check("FIX-1: 'von den' decision matches 'v.d.' record (canon_pair identity)", n_vd == 1)
|
||||
check("FIX-1: conflict cleared for v.d. record", merged_vd[0]["conflict"] is False)
|
||||
# Also verify the workaround spelling (v.d. in decision) matches a 'von den' record
|
||||
_json.dump({"resolutions": [
|
||||
{"name": "Victoria Welby gen. Welby v.d. Kleinen Chaoten", # workaround: v.d. in decision
|
||||
"dob": "16.01.2023", "decision": "E-locus = ee[f]",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp", "source": "test"},
|
||||
]}, open(dec_vd, "w", encoding="utf-8"))
|
||||
merged_vd2 = [
|
||||
{"id": "vw2", "name": "Victoria Welby gen. Welby von den Kleinen Chaoten", # record 'von den'
|
||||
"dob": "16.01.2023", "conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {}, "rawGenotype": "", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_vd2 = [{"id": "vw2"}]
|
||||
n_vd2 = e.apply_conflict_decisions(merged_vd2, conflicts_vd2, dec_vd)
|
||||
check("FIX-1: v.d. decision also matches 'von den' record (both spellings match)", n_vd2 == 1)
|
||||
try: os.remove(dec_vd)
|
||||
except OSError: pass
|
||||
|
||||
# FIX-1 C3-rule: same name+DOB, two Zuchten -> decision hits ONLY the correct Zucht (C3 isolation)
|
||||
dec_c3 = os.path.join(tempfile.gettempdir(), "decisions-c3.json")
|
||||
_json.dump({"resolutions": [
|
||||
# Decision only for Luna from ZdkC, NOT Luna from Black Forest
|
||||
{"name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
||||
"decision": "D-locus = DD", "genotype": "aa CC DD ee gg PP spsp rere", "source": "test"},
|
||||
]}, open(dec_c3, "w", encoding="utf-8"))
|
||||
merged_c3 = [
|
||||
{"id": "luna-kc", "name": "Luna von den Kleinen Chaoten", "dob": "01.01.2020",
|
||||
"conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
||||
{"id": "luna-bf", "name": "Luna of Black Forest", "dob": "01.01.2020",
|
||||
"conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"D": ["D","?"]}, "rawGenotype": "D-", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_c3 = [{"id": "luna-kc"}, {"id": "luna-bf"}]
|
||||
n_c3 = e.apply_conflict_decisions(merged_c3, conflicts_c3, dec_c3)
|
||||
check("FIX-1 C3: decision hits only the correct Zucht (luna-kc resolved)", n_c3 == 1)
|
||||
check("FIX-1 C3: luna-kc conflict cleared (correct Zucht)", merged_c3[0]["conflict"] is False)
|
||||
check("FIX-1 C3: luna-bf conflict NOT cleared (different Zucht)", merged_c3[1]["conflict"] is True)
|
||||
check("FIX-1 C3: conflicts list has only luna-bf left", len(conflicts_c3) == 1 and conflicts_c3[0]["id"] == "luna-bf")
|
||||
try: os.remove(dec_c3)
|
||||
except OSError: pass
|
||||
|
||||
# --- correctDob: a wrong-birthdate duplicate is remapped BEFORE dedup so it merges ---
|
||||
dec2 = os.path.join(tempfile.gettempdir(), "decisions-dob.json")
|
||||
_json.dump({"resolutions": [
|
||||
{"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)
|
||||
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) ---
|
||||
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("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||
|
||||
if failed:
|
||||
print(f"\n{failed} test(s) FAILED")
|
||||
sys.exit(1)
|
||||
print("\nALL PASS")
|
||||
71
tools/import/test_genotype.py
Normal file
71
tools/import/test_genotype.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Zero-dep tests for genotype.py GEN-3b normalization.
|
||||
|
||||
Run: python test_genotype.py (exit 0 = all pass)
|
||||
Covers: Uw/uw -> G/g alias, Sls/WP second spotting locus, dea/Dea/taub
|
||||
hearing-deaf flag, WFNZ/RV/GV/DP provenance tags. Per hive/agents/god/GENETIK-notation.md.
|
||||
"""
|
||||
import sys
|
||||
import genotype as g
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
if not cond:
|
||||
print(f"FAIL: {name}")
|
||||
check.failed += 1
|
||||
else:
|
||||
print(f"ok: {name}")
|
||||
check.failed = 0
|
||||
|
||||
|
||||
# --- Uw/uw == G/g (same locus) ---
|
||||
r = g.parse("aa Cc Dd Ee Uwuw Pp spsp rere")
|
||||
check("Uw->G: G locus mapped", r["mapped8locus"].get("G") == ["G", "g"])
|
||||
check("Uw->G: nothing left in unmapped", r["unmappedTokens"] == [])
|
||||
|
||||
r = g.parse("UwUw")
|
||||
check("UwUw -> GG", r["mapped8locus"].get("G") == ["G", "G"])
|
||||
r = g.parse("uwuw")
|
||||
check("uwuw -> gg", r["mapped8locus"].get("G") == ["g", "g"])
|
||||
r = g.parse("uw[d]uw[d]")
|
||||
check("uw[d]uw[d] -> gg (dense underwhite)", r["mapped8locus"].get("G") == ["g", "g"])
|
||||
|
||||
# Gg and Uwuw must produce the SAME mapped locus (so they stop being a conflict)
|
||||
check("Gg identical to Uwuw at G locus",
|
||||
g.parse("Gg")["mapped8locus"]["G"] == g.parse("Uwuw")["mapped8locus"]["G"])
|
||||
|
||||
# --- Sls / WP second spotting locus ---
|
||||
check("WP -> Sls het", g.parse("WP")["mapped8locus"].get("Sls") == ["Sl", "sl"])
|
||||
check("[WP] (bracketed) -> Sls het", g.parse("[WP]")["mapped8locus"].get("Sls") == ["Sl", "sl"])
|
||||
check("Sls token -> Sls het", g.parse("Sls")["mapped8locus"].get("Sls") == ["Sl", "sl"])
|
||||
check("sls -> Sls wild", g.parse("sls")["mapped8locus"].get("Sls") == ["sl", "sl"])
|
||||
check("S(l)s(l) -> Sl,sl", g.parse("S(l)s(l)")["mapped8locus"].get("Sls") == ["Sl", "sl"])
|
||||
# Sp and Sls are TWO distinct loci on the same animal (Superschecke)
|
||||
r = g.parse("spsp WP")
|
||||
check("Sp + Sls coexist (two spotting loci)",
|
||||
r["mapped8locus"].get("Sp") == ["sp", "sp"] and r["mapped8locus"].get("Sls") == ["Sl", "sl"])
|
||||
|
||||
# --- deafness flag (after spsp), case-sensitive ---
|
||||
check("dea (lower) -> deaf True", g.parse("spsp dea")["deaf"] is True)
|
||||
check("taub -> deaf True", g.parse("taub")["deaf"] is True)
|
||||
check("Dea (upper) -> hearing False", g.parse("spsp Dea")["deaf"] is False)
|
||||
check("(hörend) -> hearing False", g.parse("(hörend)")["deaf"] is False)
|
||||
check("no deaf token -> None", g.parse("aa Cc")["deaf"] is None)
|
||||
# deafness is NOT a genotype locus and must not pollute mapped/unmapped silently
|
||||
check("deaf flag not in unmapped", "dea" not in g.parse("spsp dea")["unmappedTokens"])
|
||||
|
||||
# --- provenance / breeding tags (never genotype, never conflict) ---
|
||||
check("WFNZ -> tag", g.parse("aa WFNZ")["tags"] == ["WFNZ"])
|
||||
check("RV -> tag", g.parse("(RV)")["tags"] == ["RV"])
|
||||
check("GV -> tag", g.parse("(GV)")["tags"] == ["GV"])
|
||||
check("DP -> tag", g.parse("[DP]")["tags"] == ["DP"])
|
||||
check("tag not in genotype loci", g.parse("WFNZ")["mapped8locus"] == {})
|
||||
check("tag not in unmapped", g.parse("aa WFNZ")["unmappedTokens"] == [])
|
||||
|
||||
# --- looks_like_genotype recognizes Uw-bearing cells ---
|
||||
check("looks_like_genotype sees Uw as G",
|
||||
g.looks_like_genotype("aa Cc Uwuw") is True)
|
||||
|
||||
if check.failed:
|
||||
print(f"\n{check.failed} test(s) FAILED")
|
||||
sys.exit(1)
|
||||
print("\nALL PASS")
|
||||
@@ -78,6 +78,46 @@ def read_cells(z, sheet_path, ss=None):
|
||||
return cells
|
||||
|
||||
|
||||
def cell_fill_sex(z, sheet_path):
|
||||
"""{(colnum, row): 'male' | 'female'} from the cell's box fill colour.
|
||||
|
||||
Breeder convention (Julian, 2026-06-06): a BLUE box = männlich (male), a WHITE box =
|
||||
weiblich (female). In these Stammbaum templates every coloured box uses one solid theme
|
||||
fill (Office accent5 = blue); unfilled/none cells render white. So: a cell whose style
|
||||
uses a real solid fill -> 'male'; an unfilled (none/gray125) cell -> 'female'.
|
||||
"""
|
||||
try:
|
||||
st = z.read("xl/styles.xml").decode("utf-8")
|
||||
except KeyError:
|
||||
return {}
|
||||
fills = re.search(r"<fills.*?</fills>", st, re.S)
|
||||
colored = set()
|
||||
if fills:
|
||||
for i, fb in enumerate(re.findall(r"<fill>(.*?)</fill>", fills.group(0), re.S)):
|
||||
if 'patternType="solid"' in fb and re.search(r"<fgColor\s", fb) and "gray125" not in fb:
|
||||
colored.add(i) # fillId of a real solid colour (blue)
|
||||
xfs = re.search(r"<cellXfs.*?</cellXfs>", st, re.S)
|
||||
idx2fill = {}
|
||||
if xfs:
|
||||
for i, xf in enumerate(re.findall(r"<xf\b([^>]*?)/?>", xfs.group(0))):
|
||||
m = re.search(r'fillId="(\d+)"', xf)
|
||||
idx2fill[i] = int(m.group(1)) if m else 0
|
||||
raw = z.read(sheet_path).decode("utf-8")
|
||||
out = {}
|
||||
for m in re.finditer(r"<c\s+([^>]*?)>", raw):
|
||||
a = dict(_ATTR.findall(m.group(1)))
|
||||
ref = a.get("r")
|
||||
if not ref:
|
||||
continue
|
||||
mm = re.match(r"([A-Z]+)(\d+)", ref)
|
||||
if not mm:
|
||||
continue
|
||||
s = int(a.get("s", "0"))
|
||||
out[(col_to_num(mm.group(1)), int(mm.group(2)))] = (
|
||||
"male" if idx2fill.get(s, 0) in colored else "female")
|
||||
return out
|
||||
|
||||
|
||||
def header_row(cells):
|
||||
"""Return {colnum: header_text} for the topmost row that has text."""
|
||||
if not cells:
|
||||
|
||||
Reference in New Issue
Block a user