Compare commits
22 Commits
feature/st
...
7c12a65938
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| b72e0a9d9a | |||
| 12d85d9796 | |||
| 76bdde4649 | |||
| 025bca1840 | |||
| 58dc811574 | |||
| 367304940f | |||
| 7e1acdb5e4 | |||
| 5ee6a4edf6 | |||
| ea527c7ec6 | |||
| c80adcf060 | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 |
@@ -9,10 +9,10 @@
|
||||
# 2. npm test + npm run build (Frontend)
|
||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||
#
|
||||
# Registry: 192.168.2.115:13000 (internes Gitea Container Registry)
|
||||
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||
# Images:
|
||||
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest
|
||||
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.2.115:13000
|
||||
REGISTRY: git.rismer.de
|
||||
REGISTRY_OWNER: gulum
|
||||
DOTNET_VERSION: "10.0.x"
|
||||
NODE_VERSION: "22"
|
||||
|
||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
@@ -226,6 +227,52 @@ namespace GerbilManager.Tests
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_works_under_retrying_execution_strategy()
|
||||
{
|
||||
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
|
||||
// OnFirstExecution() at the start of ExecuteAsync, which throws
|
||||
// InvalidOperationException when it detects a user-initiated transaction
|
||||
// that was NOT opened through the strategy. This test wires the same check
|
||||
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
|
||||
// without a live Npgsql instance.
|
||||
//
|
||||
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
|
||||
// → OnFirstExecution sees active user tx → InvalidOperationException
|
||||
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
|
||||
// → OnFirstExecution: no tx yet → OK
|
||||
var conn = new SqliteConnection("DataSource=:memory:");
|
||||
conn.Open();
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseSqlite(conn)
|
||||
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
await using (conn)
|
||||
await using (db)
|
||||
{
|
||||
WriteLitters(Array.Empty<object>());
|
||||
WriteAnimals(new[]
|
||||
{
|
||||
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
|
||||
owner = "Retry Adopter", abgabeDate = "01.03.2023",
|
||||
deathDate = "", deathCause = "", farbschlag = "" }
|
||||
});
|
||||
|
||||
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
|
||||
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||
|
||||
Assert.True(report.Executed);
|
||||
Assert.Equal(1, report.Created);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
Assert.Equal(1, await db.Contacts.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||
|
||||
[Fact]
|
||||
@@ -273,4 +320,24 @@ namespace GerbilManager.Tests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
|
||||
/// OnFirstExecution() throws when it detects a user-initiated transaction
|
||||
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
|
||||
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
|
||||
/// </summary>
|
||||
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
|
||||
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
|
||||
{
|
||||
protected override bool ShouldRetryOn(Exception exception) => false;
|
||||
}
|
||||
|
||||
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
|
||||
: IExecutionStrategyFactory
|
||||
{
|
||||
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||
/// fence strip), 503-not-configured path, upstream-error path.
|
||||
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping,
|
||||
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
@@ -26,6 +26,14 @@ namespace GerbilManager.Tests
|
||||
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]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
@@ -33,7 +41,10 @@ namespace GerbilManager.Tests
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", 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]
|
||||
@@ -54,6 +65,67 @@ namespace GerbilManager.Tests
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -291,7 +291,7 @@ public class ApplicationContext : DbContext
|
||||
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||
{
|
||||
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere", 0),
|
||||
("REW", "AA chch DD EE GG pp spsp rere", 0),
|
||||
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||
// --- Zobel / colourpoint dark (ID 4) ---
|
||||
|
||||
@@ -22,7 +22,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||
///
|
||||
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||
/// Execute wraps all writes in a single transaction (atomic: crash → full rollback).
|
||||
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
|
||||
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
|
||||
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
|
||||
/// to re-run on transient-failure retry.
|
||||
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||
/// </summary>
|
||||
public sealed class ImportDocxService
|
||||
@@ -94,6 +97,11 @@ namespace GerbilManagerWebAPI.Import
|
||||
.GroupBy(c => NormalizeName(c.Name))
|
||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||
|
||||
// Snapshot of DB contacts before any writes.
|
||||
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
|
||||
// but would remain in the in-memory dict without this reset).
|
||||
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
|
||||
|
||||
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||
var colorVarietyByName = (await _db.ColorVarieties
|
||||
.Select(cv => new { cv.Id, cv.Name })
|
||||
@@ -105,19 +113,15 @@ namespace GerbilManagerWebAPI.Import
|
||||
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||
|
||||
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||
// Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes.
|
||||
var externalRefOrdinals = new Dictionary<string, int>();
|
||||
|
||||
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||
var batchRefs = new HashSet<string>();
|
||||
|
||||
// --- Planning pass (dry-run counts + execute writes) ---
|
||||
// Execute path is wrapped in a single transaction for atomicity.
|
||||
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||
if (execute)
|
||||
tx = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
// Inner loop — shared by dry-run and execute paths.
|
||||
// All local variables above are captured by reference (C# closure), so the strategy
|
||||
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
|
||||
async Task RunLoopAsync()
|
||||
{
|
||||
foreach (var da in docxAnimals)
|
||||
{
|
||||
@@ -276,21 +280,36 @@ namespace GerbilManagerWebAPI.Import
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
|
||||
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
if (tx is not null) await tx.CommitAsync();
|
||||
}
|
||||
catch
|
||||
|
||||
if (!execute)
|
||||
{
|
||||
// tx.DisposeAsync (in finally) rolls back if not committed
|
||||
throw;
|
||||
// Dry-run: just count, no writes, no transaction needed.
|
||||
await RunLoopAsync();
|
||||
}
|
||||
finally
|
||||
else
|
||||
{
|
||||
if (tx is not null) await tx.DisposeAsync();
|
||||
// Execute: wrap the entire transaction in the execution strategy so that providers
|
||||
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
|
||||
// The lambda resets all mutable state at the top so retries start clean.
|
||||
var strategy = _db.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Reset mutable state — idempotent on strategy retry
|
||||
_db.ChangeTracker.Clear();
|
||||
externalRefOrdinals.Clear();
|
||||
batchRefs.Clear();
|
||||
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
|
||||
ownerLinked = 0; ownerCreated = 0; skipped = 0;
|
||||
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
|
||||
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync();
|
||||
await RunLoopAsync();
|
||||
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
});
|
||||
}
|
||||
|
||||
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||
|
||||
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameREW : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "REW");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "Pink Eyed White (PEW)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||
Name = "Pink Eyed White (PEW)",
|
||||
Name = "REW",
|
||||
SortOrder = 0
|
||||
},
|
||||
new
|
||||
|
||||
@@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names
|
||||
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(
|
||||
string? letter, string? gender, string? usages, int count,
|
||||
CancellationToken ct = default)
|
||||
@@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names
|
||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||
"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)
|
||||
{
|
||||
@@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names
|
||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||
|
||||
bool hasThematic = false;
|
||||
if (!string.IsNullOrWhiteSpace(usages))
|
||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
{
|
||||
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($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||
return sb.ToString();
|
||||
|
||||
@@ -19,63 +19,61 @@ Feinschliff** — siehe unten._
|
||||
| # | Was gebraucht wird | Schaltet frei |
|
||||
|---|---|---|
|
||||
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
||||
| **A4** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
|
||||
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
|
||||
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo → Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) |
|
||||
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
|
||||
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
|
||||
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
|
||||
|
||||
## B. Kleine Aktion (jederzeit)
|
||||
|
||||
| # | Aktion | Warum |
|
||||
|---|---|---|
|
||||
| **B2** | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt (http://192.168.2.124:5173). |
|
||||
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
|
||||
|
||||
## C. Genetik-Feinschliff (optional, blockiert nichts)
|
||||
|
||||
| # | Frage | Betrifft |
|
||||
|---|---|---|
|
||||
| **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. |
|
||||
| **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). |
|
||||
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
|
||||
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
|
||||
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||
|
||||
## E. Charakterbogen-Eigenschaften (optional)
|
||||
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
|
||||
|
||||
Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?**
|
||||
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
|
||||
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
|
||||
|
||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
||||
|
||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?)
|
||||
- **E2** — Auch ehrliche **negative** Eigenschaften aufnehmen (z. B. *beißt manchmal*) für ehrliche Inserate?
|
||||
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
|
||||
|
||||
---
|
||||
|
||||
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
||||
|
||||
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||
|
||||
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
|
||||
|
||||
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||
| Tier | Sterbedatum — welches? |
|
||||
|---|---|
|
||||
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
|
||||
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
|
||||
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
|
||||
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 |
|
||||
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
|
||||
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|
||||
|---|---|---|
|
||||
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
|
||||
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
|
||||
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
|
||||
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
|
||||
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||
|
||||
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||
| Tier | Konflikt — was stimmt? |
|
||||
|---|---|
|
||||
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** |
|
||||
| Percy of little runners (*16.12.2017) | P-Locus: **PP** ↔ **Pp** |
|
||||
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** ↔ **Pp** |
|
||||
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-** ↔ **Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** |
|
||||
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
|
||||
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
|
||||
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** |
|
||||
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
|
||||
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|
||||
|---|---|---|
|
||||
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx |
|
||||
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
|
||||
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
|
||||
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
|
||||
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
|
||||
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
|
||||
|
||||
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
|
||||
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
|
||||
|
||||
---
|
||||
|
||||
@@ -85,9 +83,9 @@ Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgeta
|
||||
|---|---|---|
|
||||
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
|
||||
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
|
||||
| Öffentliche Webseite + KI-Chat-Editor | gebaut | **A4** (Domain + Cloudflare) |
|
||||
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
|
||||
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
|
||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — |
|
||||
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
|
||||
|
||||
---
|
||||
|
||||
@@ -46,8 +46,8 @@ describe('Fraction', () => {
|
||||
describe('Genotype serialization', () => {
|
||||
it('round-trips display string <-> structured form', () => {
|
||||
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(fromJSON(toJSON(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')
|
||||
})
|
||||
|
||||
it('parses multi-char C-series alleles via maximal munch', () => {
|
||||
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
|
||||
expect(g.P).toEqual(['P', 'p'])
|
||||
})
|
||||
|
||||
it('wild type is AA CC DD EE GG PP spsp rere', () => {
|
||||
expect(toDisplayString(wildType())).toBe('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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
|
||||
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
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(result.warnings).toHaveLength(0)
|
||||
})
|
||||
@@ -185,15 +185,11 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
// The first 18 are the frozen ColorVariety keys — their representative
|
||||
// genotype MUST resolve back to their own name, never a later variety.
|
||||
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
|
||||
// because the REW engine check (both C-alleles reduced + pp) fires first.
|
||||
// PEW stays in the catalog as a user-pickable import name; its computed
|
||||
// farbschlag is intentionally 'REW' per Julian's extended rule.
|
||||
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
|
||||
// The first 17 frozen ColorVariety keys — their representative genotype
|
||||
// MUST resolve back to their own name, never a later variety.
|
||||
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
|
||||
// chch+pp → REW engine check → 'REW' = entry.name.
|
||||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||||
if (REW_SHADOWED.has(entry.name)) continue
|
||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
||||
}
|
||||
})
|
||||
@@ -224,11 +220,12 @@ describe('Farbschlag catalog', () => {
|
||||
|
||||
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
|
||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
||||
// 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-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(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', () => {
|
||||
@@ -289,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
|
||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||
'AA CC DD EE Gg PP spsp rere',
|
||||
'AA CC DD EE Gg PP spsp',
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||
})
|
||||
@@ -301,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'])
|
||||
})
|
||||
|
||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
|
||||
// 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(
|
||||
'AA CC DD EE GG PP spsp rere Slsl',
|
||||
'AA CC DD EE GG PP spsp Slsl',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -334,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
|
||||
})
|
||||
|
||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||
@@ -367,11 +370,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
|
||||
})
|
||||
it("'?' and '-' inputs are equivalent", () => {
|
||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||
'Aa C- DD EE GG Pp spsp rere',
|
||||
'Aa C- DD EE GG Pp spsp',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -396,10 +399,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
|
||||
|
||||
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
|
||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
|
||||
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
||||
expect(name).toBe('Schwarz')
|
||||
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||
expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||
})
|
||||
|
||||
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
||||
@@ -549,15 +552,16 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
})
|
||||
|
||||
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
||||
// hom cchm/cchm + pp
|
||||
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
|
||||
// hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
|
||||
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-)
|
||||
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
|
||||
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
||||
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
||||
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
||||
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
||||
// het cchm/ch + pp (Julian: also REW)
|
||||
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
||||
// ch/ch + pp (Julian: also REW — subsumes PEW)
|
||||
// ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
|
||||
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
||||
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
||||
// Counterproof: full C present → NOT REW (residual pigment)
|
||||
@@ -593,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]', () => {
|
||||
// Fuchsschimmel: E=[ef,ef] hom
|
||||
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
|
||||
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
|
||||
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',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -611,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.
|
||||
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||
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)', () => {
|
||||
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])', () => {
|
||||
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) ─────────────────────
|
||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||||
const display = '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'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.E).toEqual(['ef', 'e'])
|
||||
expect(g.C).toEqual(['C', '?'])
|
||||
@@ -636,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.
|
||||
})
|
||||
|
||||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
||||
const display = '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'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['cchm', 'ch'])
|
||||
expect(g.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
})
|
||||
|
||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
||||
const display = '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'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
expect(g.D).toEqual(['d', 'd'])
|
||||
@@ -668,7 +672,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
||||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||
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-', () => {
|
||||
@@ -680,6 +684,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||
expect(g.D).toEqual(['D', 'd'])
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
||||
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
||||
// to the frozen-name rule; the ColorVariety seed drops it too.
|
||||
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||
{ name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
||||
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
||||
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
||||
@@ -268,7 +268,7 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
||||
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
||||
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
||||
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
|
||||
// ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here)
|
||||
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
||||
const [c0, c1] = resolvedPair(g, 'C')
|
||||
const [p0, p1] = resolvedPair(g, 'P')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"name": "REW",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
|
||||
@@ -1,426 +1,426 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"name": "REW",
|
||||
"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,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"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,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"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,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"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,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"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,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp",
|
||||
"sortOrder": 5,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"english": "Black",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp",
|
||||
"sortOrder": 7,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"english": "Slate",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||
"sortOrder": 9,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"english": "Blue",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 11,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"english": "Lilac",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||
"sortOrder": 13,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"english": "Argente Nutmeg",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp",
|
||||
"sortOrder": 16,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp",
|
||||
"sortOrder": 18,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp",
|
||||
"sortOrder": 20,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Agouti",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Silberagouti",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Anthrazit",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Algierfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp",
|
||||
"sortOrder": 32
|
||||
},
|
||||
{
|
||||
"name": "Dilute Goldfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd ee GG pp spsp",
|
||||
"sortOrder": 33
|
||||
},
|
||||
{
|
||||
"name": "Dilute Rotfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee GG pp spsp",
|
||||
"sortOrder": 34
|
||||
},
|
||||
{
|
||||
"name": "Dilute Polarfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC dd ee gg PP spsp",
|
||||
"sortOrder": 35
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||
"sortOrder": 42,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 44,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||
"sortOrder": 45,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||
"sortOrder": 47,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||
"sortOrder": 49,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 50,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||
"sortOrder": 51,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Topas",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||
"sortOrder": 52,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -101,18 +101,21 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||
* the colour catalog stay byte-identical; it only appears for WP/Sls carriers
|
||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||
* convention) — e.g. ['C','?'] renders "C-".
|
||||
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
||||
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
|
||||
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
|
||||
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
|
||||
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
|
||||
* 8-locus strings and the colour catalog stay byte-identical; it only appears for
|
||||
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
|
||||
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
|
||||
* 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 {
|
||||
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) => {
|
||||
const [a, b] = displayPair(locus, g[locus])
|
||||
|
||||
@@ -27,7 +27,7 @@ import { getInbreedingCoefficient } from '../api/pedigree'
|
||||
import type { Gender, Gerbil } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||
import {
|
||||
DEFAULT_GENERATIONS,
|
||||
ancestorsAt,
|
||||
@@ -512,7 +512,11 @@ function PrintCell({
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,6 @@ export default function WurfDetailPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3>{t.detail.expectedColors}</h3>
|
||||
{expected ? (
|
||||
<BreedingResultView result={expected} title={t.detail.expectedColors} />
|
||||
) : (
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface ChipColor {
|
||||
|
||||
const CHIP_COLORS: Record<string, ChipColor> = {
|
||||
// Colourpoint / C-Serie
|
||||
// GEN-4c: PEW renamed to REW in catalog (Julian: PEW=REW). Both kept here for
|
||||
// backward-compat during DB migration (old animals may still carry the old name).
|
||||
'REW': { bg: '#f7f3ec', fg: '#8a7d6b' },
|
||||
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
|
||||
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
|
||||
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },
|
||||
|
||||
@@ -119,6 +119,49 @@
|
||||
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||
"dateOfDeath": "12.12.2019",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||
},
|
||||
{
|
||||
"name": "Kazumi von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Filou von den Kleinen Chaoten",
|
||||
"dob": "24.11.2014",
|
||||
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
|
||||
"dateOfDeath": "31.08.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Sokrates von den Kleinen Chaoten",
|
||||
"dob": "14.12.2015",
|
||||
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
|
||||
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
|
||||
"dateOfDeath": "20.05.2019",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Percy of little runners",
|
||||
"dob": "16.12.2017",
|
||||
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Iwana of little runners",
|
||||
"dob": "02.10.2018",
|
||||
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
|
||||
"genotype": "Aa CC DD Ee Gg Pp spsp",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
},
|
||||
{
|
||||
"name": "Osamu von den Kleinen Chaoten",
|
||||
"dob": "10.12.2015",
|
||||
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
|
||||
"dateOfDeath": "18.12.2020",
|
||||
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user