Compare commits
34 Commits
0651e54343
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| d5544412bd | |||
| 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 | |||
| 4dcd416ff6 | |||
| 5fd8cf4395 |
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
|
||||
@@ -191,6 +192,119 @@ namespace GerbilManager.Tests
|
||||
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()
|
||||
{
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -256,8 +256,6 @@ 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"),
|
||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
||||
@@ -267,44 +265,34 @@ public class ApplicationContext : DbContext
|
||||
("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"),
|
||||
("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"),
|
||||
("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"),
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
// 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
|
||||
@@ -81,7 +85,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
/// 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);
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0);
|
||||
|
||||
public sealed record AnimalSummary(
|
||||
int InSource,
|
||||
@@ -91,7 +96,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
int FarbschlagUnmatched,
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0);
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
|
||||
@@ -192,18 +192,52 @@ namespace GerbilManagerWebAPI.Import
|
||||
parentLinksAdded++;
|
||||
}
|
||||
|
||||
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters.
|
||||
// Used by the residency rule (b) below; augmented with existing DB litters under execute.
|
||||
// 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))
|
||||
litterParents[lid] = (
|
||||
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) ? fid : (Guid?)null,
|
||||
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) ? mid : (Guid?)null);
|
||||
{
|
||||
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: write (litters synthesized first so offspring FK resolves), then animals + photos.
|
||||
// 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.
|
||||
@@ -237,7 +271,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
// NOTE: no SaveChanges here — staged with the gerbils below.
|
||||
}
|
||||
|
||||
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
|
||||
@@ -356,9 +390,10 @@ 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();
|
||||
@@ -367,16 +402,20 @@ namespace GerbilManagerWebAPI.Import
|
||||
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.");
|
||||
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, derivedLitters),
|
||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped),
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded),
|
||||
parentLinksAdded, conflictsResolvedByDecision),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes,
|
||||
|
||||
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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,352 +343,268 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000024"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Marder",
|
||||
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 = "Siam (Marder-Hell)",
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Saphir",
|
||||
SortOrder = 24
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000026"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs",
|
||||
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 = "Saphir",
|
||||
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 efef GG PP spsp rere",
|
||||
Name = "Orangeschimmel",
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
Name = "Platin-Hell",
|
||||
SortOrder = 27
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Topas",
|
||||
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 = "Platin-Hell",
|
||||
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 = "Agouti dd",
|
||||
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 = "Silberagouti 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 = "Kohlfuchs 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 = "Anthrazit 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 cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP-Hell",
|
||||
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 = "Blaufuchs CP",
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 35
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Silberschimmel",
|
||||
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 = "Algierfuchsschimmel",
|
||||
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 cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs-Hell CP",
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
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 = "Blaufuchsschimmel",
|
||||
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 ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs, hell",
|
||||
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 = "Goldfuchs, 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 efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
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 EE GG pp spsp rere",
|
||||
Name = "Gold-Hell",
|
||||
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 cchmcchm dd EE GG PP spsp rere",
|
||||
Name = "Siam (Marder-Hell) dd",
|
||||
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 = "Marder 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 = "Zobel-Hell",
|
||||
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 = "Silberagouti dd CP",
|
||||
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-Hell 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 = "Agouti 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-Hell 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 CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs, hell",
|
||||
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 efef GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
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 ee gg PP spsp rere",
|
||||
Name = "Polarfuchs, hell",
|
||||
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 efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, 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 ee GG pp spsp rere",
|
||||
Name = "Rotfuchs, 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 cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel dd",
|
||||
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 CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs-Hell",
|
||||
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 cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs CP",
|
||||
SortOrder = 60
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs CP",
|
||||
SortOrder = 61
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "Silberagouti CP",
|
||||
SortOrder = 62
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Agouti CP",
|
||||
SortOrder = 63
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs-Hell CP",
|
||||
SortOrder = 64
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||
CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs,hell CP",
|
||||
SortOrder = 65
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs CP",
|
||||
SortOrder = 66
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs, hell",
|
||||
SortOrder = 67
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "Topas dd",
|
||||
SortOrder = 68
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||
CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell dd",
|
||||
SortOrder = 69
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000071"),
|
||||
CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel CP",
|
||||
SortOrder = 70
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000072"),
|
||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||
Name = "Blaufuchs dd",
|
||||
SortOrder = 71
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000073"),
|
||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "CP-Orangeschimmel",
|
||||
SortOrder = 72
|
||||
SortOrder = 60
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||
| C8 | **„Himalaya" vs. „Hermelin":** Du hast gesagt `c[h]c[h]` = **Hermelin**. In unserem Katalog gibt es aktuell ZWEI Farben mit `c[h]c[h]`: **Hermelin** (`aa c[h]c[h]`, nicht-agouti) und **Himalaya** (`A- c[h]c[h]`, agouti). Gibt es bei dir „Himalaya" überhaupt, oder ist **alles** mit `c[h]c[h]` einfach **Hermelin** (dann nehmen wir „Himalaya" raus, wie bei Schwarzschimmel)? | Farbschlag-Katalog (Himalaya behalten oder entfernen) |
|
||||
| C9 | *(optional, technisch)* Bei den **CP-Fuchs-Farben**: Wodurch unterscheiden sich genetisch **CP-Fuchs** ↔ **CP-Blaufuchs** ↔ **CP-Fuchs-Hell**? (Vermutung: Blaufuchs = `dd`-Verdünnung, „-Hell" = `c[chm]c[h]` statt `c[chm]c[chm]` — stimmt das?) Aktuell rechnet das Programm alle drei als „CP-Fuchs"; mit deiner Regel können wir sie genau unterscheiden. Per Hand auswählbar sind sie schon. | Farb-Engine Feinschliff (niedrige Priorität) |
|
||||
|
||||
### 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.)*
|
||||
@@ -63,35 +65,58 @@ 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** | ⏳ offen |
|
||||
| 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** — es gibt **zwei** „Chelsea v.d. K.C." mit verschiedenem Geburtsdatum: **\*02.04.2021** und **\*15.10.2021**. Zwei verschiedene Tiere, oder ist ein Datum falsch? | ⏳ offen |
|
||||
|
||||
### 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) | ⏳ offen |
|
||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** (Fuchs ja/nein; DP gleich in beiden) — **das ist die Mutter von „C"!** Sobald geklärt, bekommt C auch seine Mutter. | ⏳ offen |
|
||||
| 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.)*
|
||||
|
||||
|
||||
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
|
||||
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
87
gerbil-manager-web/e2e/anfragen.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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, 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)
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
29
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
29
gerbil-manager-web/e2e/bestand.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||
import { de, expect, gotoSection, 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()
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -135,6 +135,76 @@ 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)
|
||||
}
|
||||
|
||||
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||
// generischen /gerbils/:id-Route abfangen.
|
||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||
@@ -168,6 +238,81 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,24 @@ 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[]
|
||||
}
|
||||
|
||||
export interface MockDb {
|
||||
gerbils: Gerbil[]
|
||||
litters: Litter[]
|
||||
@@ -33,6 +52,12 @@ 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
|
||||
}
|
||||
|
||||
function gerbil(
|
||||
@@ -63,6 +88,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,8 +112,9 @@ 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' },
|
||||
@@ -142,5 +170,93 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
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: '❓' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,13 +174,15 @@ describe('Farbschlag catalog', () => {
|
||||
|
||||
it('falls back to Unbekannter Farbschlag for uncatalogued genotypes', () => {
|
||||
// E-dominant (no Fuchs/Schimmel family) + an uncatalogued cchm/dd/gg/pp combo.
|
||||
const match = farbschlagFor(fromDisplayString('aa cchmcchm dd EE gg pp spsp rere'))
|
||||
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-3f: 61 after cchm CP reconciliation)', () => {
|
||||
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
||||
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
||||
expect(CATALOG_SIZE).toBe(61)
|
||||
})
|
||||
|
||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
@@ -206,7 +208,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -222,7 +228,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',
|
||||
)
|
||||
})
|
||||
@@ -399,3 +405,76 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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>', () => {
|
||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||
})
|
||||
|
||||
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) resolve to their own names', () => {
|
||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,8 +76,6 @@ 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' },
|
||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||
@@ -88,48 +86,47 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ 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' },
|
||||
// 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-3a: c[chm]c[chm] efef base = CP-Orangeschimmel (breeder C5). A unspecified
|
||||
// (listed after the A-specific cchm-Schimmel entries so they win for those).
|
||||
|
||||
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
||||
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
||||
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
||||
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
||||
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
||||
// in her live data (god: extract animals.json) so the re-import name-matches and
|
||||
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
||||
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
|
||||
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
|
||||
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
|
||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||
{ name: '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-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||
{ 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' } },
|
||||
]
|
||||
|
||||
@@ -191,25 +188,61 @@ function eFamily(g: Genotype): string | null {
|
||||
}
|
||||
|
||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
/** 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)
|
||||
// When the animal is a Fuchs/Schimmel-family genotype, only entries that
|
||||
// explicitly pin the E locus may name it (so e.g. eef can't masquerade as a
|
||||
// C-series white via wildcard C); otherwise fall back to the family name.
|
||||
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: 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 | cchm/ch -> CP-<base colour> (base computed as if C were full)
|
||||
*/
|
||||
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 -> CP-<base colour>, base as if C were full.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
return base ? `CP-${base}` : null
|
||||
}
|
||||
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
const modifiers: string[] = []
|
||||
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||
|
||||
const baseName = base?.name ?? family
|
||||
const baseName = colourpointName(g) ?? baseColourFor(g)
|
||||
if (!baseName) {
|
||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||
}
|
||||
const name = [baseName, ...modifiers].join(' ')
|
||||
return { name, base, unknown: false }
|
||||
return { name, base: null, unknown: false }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +267,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)
|
||||
}
|
||||
|
||||
@@ -154,303 +154,228 @@
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam (Marder-Hell)",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 26,
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 27,
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 28,
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 29,
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 30,
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti CP-Hell",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "agouti-cp-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "blaufuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"sortOrder": 32,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"sortOrder": 33,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"sortOrder": 34,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs-Hell CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "polarfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"sortOrder": 35,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 42,
|
||||
"sortOrder": 37,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 43,
|
||||
"sortOrder": 38,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 44,
|
||||
"sortOrder": 39,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"sortOrder": 40,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Siam (Marder-Hell) dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "siam-marder-hell-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "marder-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "silberagouti-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti-Hell dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "silberagouti-hell-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "agouti-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti-Hell dd CP",
|
||||
"canonicalGenotype": "AA cchmcchm dd EE GG PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "agouti-hell-dd-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"sortOrder": 41,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 54,
|
||||
"sortOrder": 42,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"sortOrder": 43,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"sortOrder": 44,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 57,
|
||||
"sortOrder": 45,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 58,
|
||||
"image": "zobel-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"sortOrder": 46,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 60,
|
||||
"image": "kohlfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 61,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 62,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Agouti CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 63,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs-Hell CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 64,
|
||||
"image": "algierfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs,hell CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 65,
|
||||
"image": "kohlfuchs-hell-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs CP",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 66,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 67,
|
||||
"sortOrder": 47,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 68,
|
||||
"sortOrder": 48,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell dd",
|
||||
"canonicalGenotype": "aa cchmcchm dd EE gg PP spsp rere",
|
||||
"sortOrder": 69,
|
||||
"image": "zobel-hell-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel CP",
|
||||
"canonicalGenotype": "aa cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 70,
|
||||
"image": "kohlfuchsschimmel-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 71,
|
||||
"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-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 57
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 59
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 72
|
||||
"sortOrder": 60
|
||||
}
|
||||
]
|
||||
|
||||
@@ -330,6 +330,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;
|
||||
@@ -741,4 +759,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>
|
||||
)
|
||||
}
|
||||
150
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
150
gerbil-manager-web/src/pages/AnfragenPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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 './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 */}
|
||||
<div className="filters">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
|
||||
@@ -32,6 +32,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 +52,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 +67,7 @@ export default function GerbilsPage() {
|
||||
setGender('')
|
||||
setColorVarietyId('')
|
||||
setOriginBreeder('')
|
||||
setShowExternal(false)
|
||||
setPage(1)
|
||||
setSort('nameAsc')
|
||||
}
|
||||
@@ -186,6 +192,14 @@ export default function GerbilsPage() {
|
||||
<option value="birthAsc">{t.sort.birthAsc}</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
<button type="button" className="btn" onClick={resetFilters}>
|
||||
{t.filters.reset}
|
||||
</button>
|
||||
@@ -234,7 +248,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>
|
||||
)
|
||||
}
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
61
tools/import/conflict-decisions.json
Normal file
61
tools/import/conflict-decisions.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"_doc": "Human conflict resolutions for the import quarantine (HUMANQUESTION section D / C6). The importer consumes this to UN-QUARANTINE an animal: for a matching (name + dob) it accepts the given authoritative field(s) — `genotype`, `farbschlag`, and/or `dateOfDeath` (DD.MM.YYYY) — and skips the conflict. Key match = normalize(name) + dob, same identity as dedup. Maintained by god (Michael) as Julian/his wife answer the D-conflicts; originals (xlsx) stay read-only.",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -211,6 +211,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:
|
||||
@@ -221,7 +226,7 @@ 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))
|
||||
@@ -829,6 +834,43 @@ def write_report(merged, conflicts, orphans, raw_count, litters, photo_count,
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
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 = norm_name(name)+norm_dob(dob) (same identity as dedup). 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 = {}
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
for r in (json.load(fh).get("resolutions") or []):
|
||||
decisions[(norm_name(r.get("name", "")), norm_dob(r.get("dob", "")))] = r
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
if not decisions:
|
||||
return 0
|
||||
|
||||
resolved = 0
|
||||
for a in merged:
|
||||
d = decisions.get((norm_name(a["name"]), norm_dob(a["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")
|
||||
@@ -863,6 +905,8 @@ def main():
|
||||
print(f"Wurfchronik: {len(litters)} Würfe")
|
||||
|
||||
merged, conflicts, orphans, zucht_splits = dedup(raw_animals)
|
||||
decisions_path = os.path.join(HERE, "conflict-decisions.json")
|
||||
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)
|
||||
|
||||
@@ -879,7 +923,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"| 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 "
|
||||
|
||||
@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
||||
- Nach Zusammenführung (eindeutige Tiere): **622**
|
||||
- davon mit Geburtsdatum: 327
|
||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||
- Konflikte zur Klärung: **21**
|
||||
- Konflikte zur Klärung: **19**
|
||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
||||
- Fotos zugeordnet: **137**
|
||||
- Würfe aus der Wurfchronik: **752**
|
||||
@@ -27,9 +27,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
||||
|---|---|---|---|---|---|
|
||||
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
||||
| Louis von den Kleinen Chaoten | 15.07.2017 | Aa Cc[] D- Ee Gg P- spsp // Aa Cc[chm] D- Ee Uwuw[d] P- spsp | — | 01.07.2020 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -142,7 +140,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
||||
|
||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||
|---|---|---|
|
||||
| `/+` | 8 | ? |
|
||||
| `/+` | 7 | ? |
|
||||
| `-g` | 2 | ? |
|
||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
|
||||
|
||||
116
tools/import/test_extract.py
Normal file
116
tools/import/test_extract.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""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)
|
||||
try: os.remove(dec_path)
|
||||
except OSError: pass
|
||||
|
||||
# --- 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")
|
||||
Reference in New Issue
Block a user