Compare commits
9 Commits
feature/st
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ecf2022d | |||
| 04971bf3bb | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| c452b69dd6 |
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||||
|
public class CmsPublishTests
|
||||||
|
{
|
||||||
|
private static string TempRoot() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var files = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["index.html"] = "<html>start</html>",
|
||||||
|
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||||
|
["assets/site.css"] = "body {}",
|
||||||
|
};
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||||
|
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||||
|
Assert.Equal("<html>start</html>",
|
||||||
|
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||||
|
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||||
|
Assert.Equal("version-2", content);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||||
|
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_mehrfach_ohne_Fehler()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int i = 1; i <= 3; i++)
|
||||||
|
{
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("v3",
|
||||||
|
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||||
|
{
|
||||||
|
var root = TempRoot();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||||
|
await CmsEndpoints.PublishToDirectoryAsync(
|
||||||
|
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(
|
||||||
|
Path.Combine(root, "live", "index.html"),
|
||||||
|
System.Text.Encoding.UTF8);
|
||||||
|
Assert.Equal(german, content);
|
||||||
|
}
|
||||||
|
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
|
|||||||
namespace GerbilManager.Tests
|
namespace GerbilManager.Tests
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||||
/// fence strip), 503-not-configured path, upstream-error path.
|
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class NameSuggestionTests
|
public class NameSuggestionTests
|
||||||
{
|
{
|
||||||
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("origin", prompt);
|
Assert.Contains("origin", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_erklärt_thematische_Kategorien()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||||
|
Assert.Contains("thematischen Kategorien", prompt);
|
||||||
|
Assert.Contains("Erfinde KEINE Etymologie", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||||
{
|
{
|
||||||
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("6", prompt);
|
Assert.Contains("6", prompt);
|
||||||
Assert.Contains("\"A\"", prompt);
|
Assert.Contains("\"A\"", prompt);
|
||||||
Assert.Contains("weibliche", prompt);
|
Assert.Contains("weibliche", prompt);
|
||||||
Assert.Contains("norn,mythg", prompt);
|
// codes are mapped to German descriptions
|
||||||
|
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||||
|
Assert.Contains("Griechische Mythologie", prompt);
|
||||||
|
Assert.Contains("Kulturkreisen", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
|
|||||||
Assert.DoesNotContain("männliche", prompt);
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("disney", "Disney-Charaktere")]
|
||||||
|
[InlineData("pokemon", "Pokémon-Namen")]
|
||||||
|
[InlineData("encities", "Namen englischer Städte")]
|
||||||
|
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||||
|
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||||
|
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
|
||||||
|
{
|
||||||
|
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
|
||||||
|
Assert.Equal(expectedDescription, entry.Description);
|
||||||
|
Assert.True(entry.Thematic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("disney", "Disney-Charaktere")]
|
||||||
|
[InlineData("pokemon", "Pokémon-Namen")]
|
||||||
|
[InlineData("encities", "Namen englischer Städte")]
|
||||||
|
[InlineData("hrcities", "Namen kroatischer Städte")]
|
||||||
|
[InlineData("usstates", "Namen von US-Bundesstaaten")]
|
||||||
|
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
|
||||||
|
Assert.Contains(expectedDescription, prompt);
|
||||||
|
Assert.Contains("Themen-Kategorien", prompt);
|
||||||
|
Assert.DoesNotContain("Kulturkreisen", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
|
||||||
|
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
|
||||||
|
Assert.Contains("Kulturkreisen", prompt);
|
||||||
|
Assert.Contains("Disney-Charaktere", prompt);
|
||||||
|
Assert.Contains("Themen-Kategorien", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
|
||||||
|
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
|
||||||
|
{
|
||||||
|
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
|
||||||
|
var handler = new StubHandler(_ => Canned(payload));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync("D", null, "disney", 1);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.NotNull(result.Suggestions);
|
||||||
|
Assert.Equal("Dumbo", result.Suggestions![0].Name);
|
||||||
|
Assert.Equal("Disney", result.Suggestions[0].Origin);
|
||||||
|
}
|
||||||
|
|
||||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Cms
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||||
|
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||||
|
/// In production this volume is shared with the publicsite-nginx container.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PublicSiteOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PublicSite";
|
||||||
|
|
||||||
|
public string? RootPath { get; set; }
|
||||||
|
|
||||||
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using GerbilManagerWebAPI.Cms;
|
using GerbilManagerWebAPI.Cms;
|
||||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
|||||||
using GerbilManagerWebAPI.Models;
|
using GerbilManagerWebAPI.Models;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.Endpoints
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
{
|
{
|
||||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
|
||||||
|
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
|
||||||
|
{
|
||||||
|
if (!opts.Value.IsConfigured)
|
||||||
|
return Results.Problem(
|
||||||
|
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
|
||||||
|
statusCode: 503,
|
||||||
|
title: "PublicSite nicht konfiguriert");
|
||||||
|
|
||||||
|
var snapshot = await new SiteSnapshotService(db).BuildAsync();
|
||||||
|
var files = SiteRenderer.Render(snapshot);
|
||||||
|
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
|
||||||
|
return Results.Ok(new { filesPublished = files.Count });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
|
||||||
// und liefert die Datei mit passendem Content-Type aus. Relative
|
// und liefert die Datei mit passendem Content-Type aus. Relative
|
||||||
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
// Links/CSS der gerenderten Seite funktionieren dadurch im
|
||||||
@@ -169,6 +186,36 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
|
||||||
|
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task PublishToDirectoryAsync(
|
||||||
|
IReadOnlyDictionary<string, string> files, string rootPath)
|
||||||
|
{
|
||||||
|
var stagingDir = Path.Combine(rootPath, "_staging_new");
|
||||||
|
var liveDir = Path.Combine(rootPath, "live");
|
||||||
|
var oldDir = Path.Combine(rootPath, "_old");
|
||||||
|
|
||||||
|
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
|
||||||
|
Directory.CreateDirectory(stagingDir);
|
||||||
|
|
||||||
|
foreach (var (relativePath, content) in files)
|
||||||
|
{
|
||||||
|
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
var fullPath = Path.Combine(stagingDir, normalPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||||
|
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic swap: _staging_new → live
|
||||||
|
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
|
||||||
|
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
|
||||||
|
Directory.Move(stagingDir, liveDir);
|
||||||
|
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
|
||||||
|
catch { /* non-fatal — old dir gone on next publish */ }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
|
||||||
private static string PreviewContentType(string path) =>
|
private static string PreviewContentType(string path) =>
|
||||||
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Known usage codes → (German description, isThematic).
|
||||||
|
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
|
||||||
|
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["norn"] = ("Nordische/Altnordische Etymologie", false),
|
||||||
|
["japa"] = ("Japanische Etymologie", false),
|
||||||
|
["mythg"] = ("Griechische Mythologie", false),
|
||||||
|
["ger"] = ("Germanische/Deutsche Etymologie", false),
|
||||||
|
["arb"] = ("Arabische Etymologie", false),
|
||||||
|
["disney"] = ("Disney-Charaktere", true),
|
||||||
|
["pokemon"] = ("Pokémon-Namen", true),
|
||||||
|
["encities"] = ("Namen englischer Städte", true),
|
||||||
|
["hrcities"] = ("Namen kroatischer Städte", true),
|
||||||
|
["usstates"] = ("Namen von US-Bundesstaaten", true),
|
||||||
|
};
|
||||||
|
|
||||||
public async Task<NameSuggestionResult> SuggestAsync(
|
public async Task<NameSuggestionResult> SuggestAsync(
|
||||||
string? letter, string? gender, string? usages, int count,
|
string? letter, string? gender, string? usages, int count,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " +
|
||||||
|
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
|
||||||
|
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
|
||||||
|
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
|
||||||
|
"Erfinde KEINE Etymologie für thematische Kategorien.";
|
||||||
|
|
||||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||||
{
|
{
|
||||||
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
|
|||||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||||
|
|
||||||
|
bool hasThematic = false;
|
||||||
if (!string.IsNullOrWhiteSpace(usages))
|
if (!string.IsNullOrWhiteSpace(usages))
|
||||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
{
|
||||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var etymDescriptions = new List<string>();
|
||||||
|
var thematicDescriptions = new List<string>();
|
||||||
|
foreach (var code in codes)
|
||||||
|
{
|
||||||
|
if (UsageMap.TryGetValue(code, out var entry))
|
||||||
|
{
|
||||||
|
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
|
||||||
|
else etymDescriptions.Add(entry.Description);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
etymDescriptions.Add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (etymDescriptions.Count > 0)
|
||||||
|
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
|
||||||
|
if (thematicDescriptions.Count > 0)
|
||||||
|
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
|
||||||
|
hasThematic = thematicDescriptions.Count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(". ");
|
||||||
|
if (hasThematic)
|
||||||
|
{
|
||||||
|
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
|
||||||
|
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
|
||||||
|
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||||
|
}
|
||||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
|||||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
|
||||||
|
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
|
||||||
|
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ KEYS_PATH=/mnt/SSD/gerbil/keys
|
|||||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||||
BACKUP_KEEP_DAYS=7
|
BACKUP_KEEP_DAYS=7
|
||||||
|
|
||||||
|
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
|
||||||
|
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
|
||||||
|
PUBLICSITE_PORT=8081
|
||||||
|
|
||||||
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||||
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
|
||||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ services:
|
|||||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||||
AI__ApiKey: "${AI__ApiKey:-}"
|
AI__ApiKey: "${AI__ApiKey:-}"
|
||||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||||
|
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
|
||||||
|
PublicSite__RootPath: /data/publicsite
|
||||||
volumes:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
- keys:/data/keys
|
- keys:/data/keys
|
||||||
|
- publicsite:/data/publicsite
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -74,6 +77,21 @@ services:
|
|||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
# --- nginx Public Site (WEB-2) ---
|
||||||
|
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
|
||||||
|
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
|
||||||
|
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
|
||||||
|
publicsite:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PUBLICSITE_PORT:-8081}:80"
|
||||||
|
volumes:
|
||||||
|
- publicsite:/usr/share/nginx/html:ro
|
||||||
|
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||||
backup:
|
backup:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -122,3 +140,10 @@ volumes:
|
|||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||||
|
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
|
||||||
|
publicsite:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
|
||||||
|
|||||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# GerbilManager — publicsite nginx (WEB-2)
|
||||||
|
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||||
|
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html/live;
|
||||||
|
index index.html;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/index.html =404;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSS/Bilder: kurze TTL (1 Tag)
|
||||||
|
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, max-age=86400";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kein Zugriff auf Staging-Verzeichnisse
|
||||||
|
location ~ ^/_(staging_new|old)/ {
|
||||||
|
return 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||||
|
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||||
|
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||||
|
#
|
||||||
|
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||||
|
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||||
|
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name <DOMAIN>;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl;
|
||||||
|
# server_name <DOMAIN>;
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://127.0.0.1:8081;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# }
|
||||||
|
# }
|
||||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
|
||||||
|
|
||||||
|
> **Zielgruppe:** Julian.
|
||||||
|
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
|
||||||
|
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
| HTTPS/HTTP
|
||||||
|
v
|
||||||
|
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
|
||||||
|
| proxy_pass http://127.0.0.1:8081
|
||||||
|
v
|
||||||
|
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
|
||||||
|
| (Shared Volume, read-only)
|
||||||
|
| [POST /api/publish im Manager schreibt in dasselbe Volume]
|
||||||
|
v
|
||||||
|
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
|
||||||
|
| (Shared Volume, read-write)
|
||||||
|
v
|
||||||
|
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
|
||||||
|
```
|
||||||
|
|
||||||
|
**SICHERHEIT — harte Bedingung:**
|
||||||
|
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
|
||||||
|
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
|
||||||
|
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erstinstallation
|
||||||
|
|
||||||
|
### 1. Verzeichnis anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
|
||||||
|
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
|
||||||
|
|
||||||
|
### 2. .env erganzen
|
||||||
|
|
||||||
|
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
|
||||||
|
|
||||||
|
```env
|
||||||
|
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||||
|
PUBLICSITE_PORT=8081
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Compose-Stack neu starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/gerbilmanager
|
||||||
|
docker compose -f deploy/truenas/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Der neue `publicsite`-Container startet und serviert Port 8081.
|
||||||
|
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
|
||||||
|
|
||||||
|
### 4. Julians externen nginx konfigurieren
|
||||||
|
|
||||||
|
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
|
||||||
|
(als eigenen `server`-Block oder per `include`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
|
||||||
|
nano /etc/nginx/conf.d/gerbilmanager-public.conf
|
||||||
|
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seite veroeffentlichen (Publish-Ablauf)
|
||||||
|
|
||||||
|
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||||
|
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
|
||||||
|
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
|
||||||
|
|
||||||
|
**Was passiert intern:**
|
||||||
|
```
|
||||||
|
POST /api/publish
|
||||||
|
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
|
||||||
|
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
|
||||||
|
→ Schreibt Dateien nach /data/publicsite/_staging_new/
|
||||||
|
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
|
||||||
|
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
|
||||||
|
→ Kein Container-Restart, kein Image-Rebuild, kein CI
|
||||||
|
Response: { "filesPublished": N }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# publicsite-Container laeuft?
|
||||||
|
docker compose -f deploy/truenas/compose.yaml ps publicsite
|
||||||
|
|
||||||
|
# Seite lokal abrufbar?
|
||||||
|
curl -s http://localhost:8081/ | head -5
|
||||||
|
|
||||||
|
# live/-Verzeichnis gefuellt?
|
||||||
|
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
|
||||||
|
|
||||||
|
# Oeffentlich erreichbar (nach DNS-Propagation)?
|
||||||
|
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheitstrennung (Pflichtcheck)
|
||||||
|
|
||||||
|
| Was | Port | Internet-exponiert? |
|
||||||
|
|-----|------|---------------------|
|
||||||
|
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
|
||||||
|
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
|
||||||
|
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
|
||||||
|
|
||||||
|
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
|
||||||
|
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
|
||||||
|
Sie sind ausschliesslich im Heimnetz erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hostname noch ausstehend
|
||||||
|
|
||||||
|
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||||
|
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||||
@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
|
|||||||
expect(codes).toContain('mythg')
|
expect(codes).toContain('mythg')
|
||||||
expect(codes).toContain('ger')
|
expect(codes).toContain('ger')
|
||||||
expect(codes).toContain('arb')
|
expect(codes).toContain('arb')
|
||||||
expect(codes).toHaveLength(5)
|
expect(codes).toContain('disney')
|
||||||
|
expect(codes).toContain('pokemon')
|
||||||
|
expect(codes).toContain('encities')
|
||||||
|
expect(codes).toContain('hrcities')
|
||||||
|
expect(codes).toContain('usstates')
|
||||||
|
expect(codes).toHaveLength(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('every usage has a non-empty label', () => {
|
it('every usage has a non-empty label', () => {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
|
|||||||
{ code: 'mythg', label: 'Griech. Mythologie' },
|
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||||
{ code: 'ger', label: 'Deutsch' },
|
{ code: 'ger', label: 'Deutsch' },
|
||||||
{ code: 'arb', label: 'Arabisch' },
|
{ code: 'arb', label: 'Arabisch' },
|
||||||
|
{ code: 'disney', label: 'Disney' },
|
||||||
|
{ code: 'pokemon', label: 'Pokémon' },
|
||||||
|
{ code: 'encities', label: 'Englische Städte' },
|
||||||
|
{ code: 'hrcities', label: 'Kroatische Städte' },
|
||||||
|
{ code: 'usstates', label: 'US-Bundesstaaten' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ describe('Fraction', () => {
|
|||||||
describe('Genotype serialization', () => {
|
describe('Genotype serialization', () => {
|
||||||
it('round-trips display string <-> structured form', () => {
|
it('round-trips display string <-> structured form', () => {
|
||||||
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
|
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
|
||||||
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('parses multi-char C-series alleles via maximal munch', () => {
|
it('parses multi-char C-series alleles via maximal munch', () => {
|
||||||
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
|
|||||||
expect(g.P).toEqual(['P', 'p'])
|
expect(g.P).toEqual(['P', 'p'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('wild type is AA CC DD EE GG PP spsp rere', () => {
|
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
|
||||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
|
|||||||
|
|
||||||
expect(result.offspring).toHaveLength(1)
|
expect(result.offspring).toHaveLength(1)
|
||||||
const only = result.offspring[0]
|
const only = result.offspring[0]
|
||||||
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
|
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
|
||||||
expect(only.probability.text).toBe('1')
|
expect(only.probability.text).toBe('1')
|
||||||
expect(result.warnings).toHaveLength(0)
|
expect(result.warnings).toHaveLength(0)
|
||||||
})
|
})
|
||||||
@@ -223,8 +223,9 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
||||||
// Every row has a non-empty canonical genotype display string and unique name.
|
// Every row has a non-empty canonical genotype display string and unique name.
|
||||||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||||
|
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
|
||||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||||
@@ -285,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
|
|||||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||||
'AA CC DD EE Gg PP spsp rere',
|
'AA CC DD EE Gg PP spsp',
|
||||||
)
|
)
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||||
})
|
})
|
||||||
@@ -297,10 +298,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
|||||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
|
||||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
|
||||||
|
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||||
|
// Rex het → Rere shown
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
|
||||||
|
'AA CC DD EE GG PP spsp Rere',
|
||||||
|
)
|
||||||
|
// WP → Slsl shown, rere still omitted
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||||||
'AA CC DD EE GG PP spsp rere Slsl',
|
'AA CC DD EE GG PP spsp Slsl',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -330,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
|||||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||||
@@ -363,11 +370,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
|||||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
|
||||||
})
|
})
|
||||||
it("'?' and '-' inputs are equivalent", () => {
|
it("'?' and '-' inputs are equivalent", () => {
|
||||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||||
'Aa C- DD EE GG Pp spsp rere',
|
'Aa C- DD EE GG Pp spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -590,15 +597,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||||
// Fuchsschimmel: E=[ef,ef] hom
|
// Fuchsschimmel: E=[ef,ef] hom
|
||||||
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||||
'AA CC DD e[f]e[f] GG PP spsp rere',
|
'AA CC DD e[f]e[f] GG PP spsp',
|
||||||
)
|
)
|
||||||
// C-locus het: cchm + ch
|
// C-locus het: cchm + ch
|
||||||
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||||
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
'aa c[chm]c[h] DD EE GG PP spsp',
|
||||||
)
|
)
|
||||||
// C-locus hom cchm
|
// C-locus hom cchm
|
||||||
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||||
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
'aa c[chm]c[chm] DD EE GG PP spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -608,24 +615,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
// Display must swap to [e, ef] per breeder convention.
|
// Display must swap to [e, ef] per breeder convention.
|
||||||
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||||
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||||||
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
|
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||||
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||||
'aa CC DD Ee GG PP spsp rere',
|
'aa CC DD Ee GG PP spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||||
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||||
'aa CC DD Ee[f] GG PP spsp rere',
|
'aa CC DD Ee[f] GG PP spsp',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => {
|
||||||
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
const display = 'aa C- D- ee[f] Gg Pp spsp'
|
||||||
const g = fromDisplayString(display)
|
const g = fromDisplayString(display)
|
||||||
expect(g.E).toEqual(['ef', 'e'])
|
expect(g.E).toEqual(['ef', 'e'])
|
||||||
expect(g.C).toEqual(['C', '?'])
|
expect(g.C).toEqual(['C', '?'])
|
||||||
@@ -633,16 +640,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp', () => {
|
||||||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
|
||||||
const g = fromDisplayString(display)
|
const g = fromDisplayString(display)
|
||||||
expect(g.C).toEqual(['cchm', 'ch'])
|
expect(g.C).toEqual(['cchm', 'ch'])
|
||||||
expect(g.E).toEqual(['E', 'e'])
|
expect(g.E).toEqual(['E', 'e'])
|
||||||
expect(toDisplayString(g)).toBe(display)
|
expect(toDisplayString(g)).toBe(display)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => {
|
||||||
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
const display = 'aa Cc[h] dd EE Gg P- Spsp'
|
||||||
const g = fromDisplayString(display)
|
const g = fromDisplayString(display)
|
||||||
expect(g.C).toEqual(['C', 'ch'])
|
expect(g.C).toEqual(['C', 'ch'])
|
||||||
expect(g.D).toEqual(['d', 'd'])
|
expect(g.D).toEqual(['d', 'd'])
|
||||||
@@ -665,7 +672,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
||||||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||||
expect(g.E).toEqual(['e', '?'])
|
expect(g.E).toEqual(['e', '?'])
|
||||||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||||||
@@ -677,6 +684,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
|||||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||||
expect(g.D).toEqual(['D', 'd'])
|
expect(g.D).toEqual(['D', 'd'])
|
||||||
expect(g.Sp).toEqual(['Sp', 'sp'])
|
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||||||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
|
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,425 +2,425 @@
|
|||||||
{
|
{
|
||||||
"name": "REW",
|
"name": "REW",
|
||||||
"english": "Pink Eyed White",
|
"english": "Pink Eyed White",
|
||||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hermelin",
|
"name": "Hermelin",
|
||||||
"english": "Dark Tailed White",
|
"english": "Dark Tailed White",
|
||||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 1,
|
"sortOrder": 1,
|
||||||
"image": "hermelin.jpeg"
|
"image": "hermelin.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Himalaya",
|
"name": "Himalaya",
|
||||||
"english": "Himalayan",
|
"english": "Himalayan",
|
||||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 2,
|
"sortOrder": 2,
|
||||||
"image": "himalaya.jpg"
|
"image": "himalaya.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel",
|
"name": "Zobel",
|
||||||
"english": "Sable",
|
"english": "Sable",
|
||||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp",
|
||||||
"sortOrder": 3,
|
"sortOrder": 3,
|
||||||
"image": "zobel.jpeg"
|
"image": "zobel.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotaugenschimmel",
|
"name": "Rotaugenschimmel",
|
||||||
"english": "Red-Eyed Roan",
|
"english": "Red-Eyed Roan",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||||
"sortOrder": 4,
|
"sortOrder": 4,
|
||||||
"image": "rotaugen-schimmel.jpg"
|
"image": "rotaugen-schimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agouti",
|
"name": "Agouti",
|
||||||
"english": "Golden Agouti",
|
"english": "Golden Agouti",
|
||||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG PP spsp",
|
||||||
"sortOrder": 5,
|
"sortOrder": 5,
|
||||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Schwarz",
|
"name": "Schwarz",
|
||||||
"english": "Black",
|
"english": "Black",
|
||||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG PP spsp",
|
||||||
"sortOrder": 6,
|
"sortOrder": 6,
|
||||||
"image": "schwarz.jpg"
|
"image": "schwarz.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberagouti",
|
"name": "Silberagouti",
|
||||||
"english": "Grey Agouti",
|
"english": "Grey Agouti",
|
||||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD EE gg PP spsp",
|
||||||
"sortOrder": 7,
|
"sortOrder": 7,
|
||||||
"image": "silberagouti.jpg"
|
"image": "silberagouti.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Anthrazit",
|
"name": "Anthrazit",
|
||||||
"english": "Slate",
|
"english": "Slate",
|
||||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD EE gg PP spsp",
|
||||||
"sortOrder": 8,
|
"sortOrder": 8,
|
||||||
"image": "anthrazit.jpg"
|
"image": "anthrazit.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs",
|
"name": "Algierfuchs",
|
||||||
"english": "Dark-Eyed Honey",
|
"english": "Dark-Eyed Honey",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||||
"sortOrder": 9,
|
"sortOrder": 9,
|
||||||
"image": "algierfuchs.jpg"
|
"image": "algierfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blau",
|
"name": "Blau",
|
||||||
"english": "Blue",
|
"english": "Blue",
|
||||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG PP spsp",
|
||||||
"sortOrder": 10,
|
"sortOrder": 10,
|
||||||
"image": "blau-schwarz-dd.JPG"
|
"image": "blau-schwarz-dd.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold",
|
"name": "Gold",
|
||||||
"english": "Argente Golden",
|
"english": "Argente Golden",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||||
"sortOrder": 11,
|
"sortOrder": 11,
|
||||||
"image": "gold.jpg"
|
"image": "gold.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Platin",
|
"name": "Platin",
|
||||||
"english": "Lilac",
|
"english": "Lilac",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||||
"sortOrder": 12,
|
"sortOrder": 12,
|
||||||
"image": "platin.JPG"
|
"image": "platin.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs",
|
"name": "Goldfuchs",
|
||||||
"english": "Yellow Fox",
|
"english": "Yellow Fox",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||||
"sortOrder": 13,
|
"sortOrder": 13,
|
||||||
"image": "goldfuchs.jpg"
|
"image": "goldfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs",
|
"name": "Rotfuchs",
|
||||||
"english": "Argente Nutmeg",
|
"english": "Argente Nutmeg",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||||
"sortOrder": 14,
|
"sortOrder": 14,
|
||||||
"image": "rotfuchs.JPG"
|
"image": "rotfuchs.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Gold",
|
"name": "Dilute Gold",
|
||||||
"english": "dd Argente Golden",
|
"english": "dd Argente Golden",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||||
"sortOrder": 15,
|
"sortOrder": 15,
|
||||||
"image": "gold-dd.jpg"
|
"image": "gold-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Platin",
|
"name": "Dilute Platin",
|
||||||
"english": "dd Lilac",
|
"english": "dd Lilac",
|
||||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG pp spsp",
|
||||||
"sortOrder": 16,
|
"sortOrder": 16,
|
||||||
"image": "platin-dd.jpg"
|
"image": "platin-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Altweiss (REW)",
|
"name": "Altweiss (REW)",
|
||||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE gg pp spsp",
|
||||||
"sortOrder": 17,
|
"sortOrder": 17,
|
||||||
"image": "altweiss-rew.jpeg"
|
"image": "altweiss-rew.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Apricot (Blassfuchs)",
|
"name": "Apricot (Blassfuchs)",
|
||||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg pp spsp",
|
||||||
"sortOrder": 18,
|
"sortOrder": 18,
|
||||||
"image": "apricot-blassfuchs.jpg"
|
"image": "apricot-blassfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs",
|
"name": "Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||||
"sortOrder": 19,
|
"sortOrder": 19,
|
||||||
"image": "blaufuchs.jpg"
|
"image": "blaufuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "C-Separator",
|
"name": "C-Separator",
|
||||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg pp spsp",
|
||||||
"sortOrder": 20,
|
"sortOrder": 20,
|
||||||
"image": "c-separator.jpg"
|
"image": "c-separator.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Elfenbein",
|
"name": "Elfenbein",
|
||||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE gg pp spsp",
|
||||||
"sortOrder": 21,
|
"sortOrder": 21,
|
||||||
"image": "elfenbein.jpg"
|
"image": "elfenbein.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs",
|
"name": "Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||||
"sortOrder": 22,
|
"sortOrder": 22,
|
||||||
"image": "kohlfuchs.jpg"
|
"image": "kohlfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs",
|
"name": "Polarfuchs",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||||
"sortOrder": 23,
|
"sortOrder": 23,
|
||||||
"image": "polarfuchs.jpg"
|
"image": "polarfuchs.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Saphir",
|
"name": "Saphir",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||||
"sortOrder": 24,
|
"sortOrder": 24,
|
||||||
"image": "saphir.jpg"
|
"image": "saphir.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Orangeschimmel",
|
"name": "Orangeschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 25,
|
"sortOrder": 25,
|
||||||
"image": "schimmel-orangeschimmel.jpg"
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas",
|
"name": "Topas",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||||
"sortOrder": 26,
|
"sortOrder": 26,
|
||||||
"image": "topas.jpg"
|
"image": "topas.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Platin-Hell",
|
"name": "Platin-Hell",
|
||||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||||
"sortOrder": 27,
|
"sortOrder": 27,
|
||||||
"image": "platin-hell.jpg"
|
"image": "platin-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Agouti",
|
"name": "Dilute Agouti",
|
||||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG PP spsp",
|
||||||
"sortOrder": 28,
|
"sortOrder": 28,
|
||||||
"image": "agouti-dd.jpg"
|
"image": "agouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Silberagouti",
|
"name": "Dilute Silberagouti",
|
||||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE gg PP spsp",
|
||||||
"sortOrder": 29,
|
"sortOrder": 29,
|
||||||
"image": "silberagouti-dd.jpg"
|
"image": "silberagouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Kohlfuchs",
|
"name": "Dilute Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG PP spsp",
|
||||||
"sortOrder": 30,
|
"sortOrder": 30,
|
||||||
"image": "kohlfuchs-dd.jpg"
|
"image": "kohlfuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Anthrazit",
|
"name": "Dilute Anthrazit",
|
||||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE gg PP spsp",
|
||||||
"sortOrder": 31,
|
"sortOrder": 31,
|
||||||
"image": "anthrazit-dd.jpg"
|
"image": "anthrazit-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Algierfuchs",
|
"name": "Dilute Algierfuchs",
|
||||||
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd ee GG PP spsp",
|
||||||
"sortOrder": 32
|
"sortOrder": 32
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Goldfuchs",
|
"name": "Dilute Goldfuchs",
|
||||||
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd ee GG pp spsp",
|
||||||
"sortOrder": 33
|
"sortOrder": 33
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Rotfuchs",
|
"name": "Dilute Rotfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG pp spsp",
|
||||||
"sortOrder": 34
|
"sortOrder": 34
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Polarfuchs",
|
"name": "Dilute Polarfuchs",
|
||||||
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd ee gg PP spsp",
|
||||||
"sortOrder": 35
|
"sortOrder": 35
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"name": "Silberschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||||
"sortOrder": 36,
|
"sortOrder": 36,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"name": "Polarfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||||
"sortOrder": 37,
|
"sortOrder": 37,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"name": "Algierfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 38,
|
"sortOrder": 38,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"name": "Kohlfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 39,
|
"sortOrder": 39,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"name": "Blaufuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
||||||
"sortOrder": 40,
|
"sortOrder": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs, hell",
|
"name": "Kohlfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||||
"sortOrder": 41,
|
"sortOrder": 41,
|
||||||
"image": "kohlfuchs-hell.jpg"
|
"image": "kohlfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs, hell",
|
"name": "Goldfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||||
"sortOrder": 42,
|
"sortOrder": 42,
|
||||||
"image": "goldfuchs-hell.jpg"
|
"image": "goldfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"name": "Goldfuchsschimmel",
|
||||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||||
"sortOrder": 43,
|
"sortOrder": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold-Hell",
|
"name": "Gold-Hell",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||||
"sortOrder": 44,
|
"sortOrder": 44,
|
||||||
"image": "gold-hell.jpg"
|
"image": "gold-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs, hell",
|
"name": "Blaufuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||||
"sortOrder": 45,
|
"sortOrder": 45,
|
||||||
"image": "blaufuchs-hell.jpeg"
|
"image": "blaufuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"name": "Rotfuchsschimmel",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
||||||
"sortOrder": 46,
|
"sortOrder": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs, hell",
|
"name": "Polarfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||||
"sortOrder": 47,
|
"sortOrder": 47,
|
||||||
"image": "polarfuchs-hell.jpeg"
|
"image": "polarfuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 48,
|
"sortOrder": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs, hell",
|
"name": "Rotfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||||
"sortOrder": 49,
|
"sortOrder": 49,
|
||||||
"image": "rotfuchs-hell.jpg"
|
"image": "rotfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs-Hell",
|
"name": "Kohlfuchs-Hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||||
"sortOrder": 50,
|
"sortOrder": 50,
|
||||||
"image": "kohlfuchs-hell-2.jpg"
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs, hell",
|
"name": "Algierfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||||
"sortOrder": 51,
|
"sortOrder": 51,
|
||||||
"image": "algierfuchs-hell.JPG"
|
"image": "algierfuchs-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Topas",
|
"name": "Dilute Topas",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||||
"sortOrder": 52,
|
"sortOrder": 52,
|
||||||
"image": "topas-dd.jpg"
|
"image": "topas-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Dilute Blaufuchs",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||||
"sortOrder": 53,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marder",
|
"name": "Marder",
|
||||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp",
|
||||||
"sortOrder": 54,
|
"sortOrder": 54,
|
||||||
"image": "marder.JPG"
|
"image": "marder.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Siam",
|
"name": "Siam",
|
||||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 55,
|
"sortOrder": 55,
|
||||||
"image": "siam-marder-hell.JPG"
|
"image": "siam-marder-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel-Hell",
|
"name": "Zobel-Hell",
|
||||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp",
|
||||||
"sortOrder": 56,
|
"sortOrder": 56,
|
||||||
"image": "zobel-hell.jpg"
|
"image": "zobel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti",
|
"name": "CP-Agouti",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp",
|
||||||
"sortOrder": 57,
|
"sortOrder": 57,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti-Hell",
|
"name": "CP-Agouti-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp",
|
||||||
"sortOrder": 58
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Silberagouti",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp",
|
||||||
"sortOrder": 59,
|
"sortOrder": 59,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti-Hell",
|
"name": "CP-Silberagouti-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp",
|
||||||
"sortOrder": 60
|
"sortOrder": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"name": "CP-Algierfuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp",
|
||||||
"sortOrder": 61,
|
"sortOrder": 61,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs-Hell",
|
"name": "CP-Algierfuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp",
|
||||||
"sortOrder": 62
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"name": "CP-Polarfuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp",
|
||||||
"sortOrder": 63,
|
"sortOrder": 63,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs-Hell",
|
"name": "CP-Polarfuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp",
|
||||||
"sortOrder": 64
|
"sortOrder": 64
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"name": "CP-Fuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp",
|
||||||
"sortOrder": 65
|
"sortOrder": 65
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"name": "CP-Fuchs-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp",
|
||||||
"sortOrder": 66
|
"sortOrder": 66
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"name": "CP-Blaufuchs",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp",
|
||||||
"sortOrder": 67
|
"sortOrder": 67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"name": "CP-Orangeschimmel",
|
||||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 68
|
"sortOrder": 68
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel-Hell",
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp",
|
||||||
"sortOrder": 69
|
"sortOrder": 69
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
|
||||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
|
||||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
|
||||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
|
||||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
* 8-locus strings and the colour catalog stay byte-identical; it only appears for
|
||||||
* convention) — e.g. ['C','?'] renders "C-".
|
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
|
||||||
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
|
||||||
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
* convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
|
||||||
|
* order E > e > e[f].
|
||||||
*/
|
*/
|
||||||
export function toDisplayString(g: Genotype): string {
|
export function toDisplayString(g: Genotype): string {
|
||||||
return LOCUS_ORDER.filter(
|
return LOCUS_ORDER.filter(
|
||||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
(locus) =>
|
||||||
|
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
|
||||||
|
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
)
|
)
|
||||||
.map((locus) => {
|
.map((locus) => {
|
||||||
const [a, b] = displayPair(locus, g[locus])
|
const [a, b] = displayPair(locus, g[locus])
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { getInbreedingCoefficient } from '../api/pedigree'
|
|||||||
import type { Gender, Gerbil, Litter } from '../api/types'
|
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel } from '../format/labels'
|
import { formatDate, genderLabel } from '../format/labels'
|
||||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||||
import {
|
import {
|
||||||
DEFAULT_GENERATIONS,
|
DEFAULT_GENERATIONS,
|
||||||
ancestorsAt,
|
ancestorsAt,
|
||||||
@@ -555,7 +555,11 @@ function PrintCell({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
|
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
|
||||||
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
|
{g.genotype && gen <= 2 && (
|
||||||
|
<div className="stammbaum-print__geno">
|
||||||
|
{toDisplayString(fromDisplayString(g.genotype))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ export default function WurfDetailPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3>{t.detail.expectedColors}</h3>
|
|
||||||
{expected ? (
|
{expected ? (
|
||||||
<BreedingResultView result={expected} title={t.detail.expectedColors} />
|
<BreedingResultView result={expected} title={t.detail.expectedColors} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user