Compare commits

...

6 Commits

Author SHA1 Message Date
2198c33898 OPS-2: TrueNAS Goldeye 25.10.2.1 Deploy finalisiert
Registry truenas:13000 -> git.rismer.de/gulum (alle 6 Stellen).
Dataset-Pfade auf /mnt/JailStorage/DockerVolumes/gerbilmanager/ gesetzt.
docs/ops.md: vollstaendiges Goldeye-Runbook (Ordner + UID-999-Perms,
Registry-Login, Port-80-Fallback, Restore-Drill-Pflichtschritt, ZFS-Pfade).
docker compose config: OK.
2026-06-07 01:38:25 +02:00
7c12a65938 Merge feature/namegen-2-be (NAMEGEN-2-BE): 5 Themen-Kategorien disney/pokemon/encities/hrcities/usstates für /names/suggest
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:34:52 +02:00
5361a292f1 NAMEGEN-2-BE: 5 neue Themen-Kategorien (disney/pokemon/encities/hrcities/usstates)
UsageMap bildet alle Codes auf deutsche Beschreibungen ab; thematische Kategorien
landen unter "Themen-Kategorien" im Prompt (kein Etymologie-Zwang, origin=Kategoriename).
22 neue Tests, 179/179 gruen.
2026-06-07 01:32:56 +02:00
cb5acd2005 Merge feature/genotype-display-rex (GEN-4d + UI-FIX): Rex-Wildtyp 'rere' in Anzeige ausblenden (7 Loci) + doppelte Wurf-Überschrift entfernt
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m0s
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
2026-06-07 01:31:19 +02:00
bda5479b93 UI-FIX: doppelte Überschrift 'Erwartete Farbschläge' auf Wurf-Detailseite entfernt 2026-06-07 01:29:49 +02:00
d5c155953b GEN-4d: rere (Re-Wildtyp) aus Anzeige ausblenden (Julian)
- genotype.ts: toDisplayString filtert Re aus wenn re/re (Wildtyp) —
  analog zur bestehenden Sls-Regel; Rere/ReRe bleiben sichtbar.
  Wildtyp-Anzeige jetzt 7 Loci: AA CC DD EE GG PP spsp (ohne rere).
- StammbaumPage.tsx: Druck-Ahnentafel nutzt toDisplayString(fromDisplayString(g.genotype))
  statt rohem g.genotype-String (Audit: einzige Display-Stelle ausserhalb toDisplayString).
  (GerbilDetailPage/BreedingResultView gehen bereits via toDisplayString.)
- genetics.test.ts: Alle Display-String-Assertions auf 7-Loci-Format aktualisiert;
  Rere-Nachweis zum Sls/Re-Test ergaenzt; Katalog-Regex auf {6,8}.
- colorVarietySeed.generated.json regeneriert (kein rere in canonicalGenotype).
- backend.json unveraendert (Pam: DB-Speicherung bleibt volles 8-Loki-Format).
2026-06-07 01:28:06 +02:00
10 changed files with 451 additions and 276 deletions

View File

@@ -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]

View File

@@ -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();

View File

@@ -7,16 +7,16 @@ POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80) # Externer Port fuer das Frontend (Standard: 80)
PORT=80 PORT=80
# Gitea Container Registry (Standard: truenas:13000/gulum) # Container Registry (git.rismer.de/gulum)
REGISTRY=truenas:13000/gulum REGISTRY=git.rismer.de/gulum
TAG=latest TAG=latest
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>) # NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
PGDATA_PATH=/mnt/SSD/gerbil/pgdata PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
PHOTOS_PATH=/mnt/SSD/gerbil/photos PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
BACKUPS_PATH=/mnt/SSD/gerbil/backups BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung) # AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/SSD/gerbil/keys KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7

View File

@@ -29,7 +29,7 @@ services:
# --- .NET API (GerbilManagerWebAPI) --- # --- .NET API (GerbilManagerWebAPI) ---
api: api:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}" image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile dockerfile: GerbilManagerWebAPI/Dockerfile
@@ -63,7 +63,7 @@ services:
# --- nginx Frontend (React SPA + API-Proxy) --- # --- nginx Frontend (React SPA + API-Proxy) ---
frontend: frontend:
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}" image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: gerbil-manager-web/Dockerfile dockerfile: gerbil-manager-web/Dockerfile
@@ -95,19 +95,19 @@ services:
volumes: volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren). # NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata # TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
pgdata: pgdata:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}" device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
photos: photos:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}" device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung. # AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos). # Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys: keys:
@@ -115,10 +115,10 @@ volumes:
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${KEYS_PATH:-/mnt/gerbil/keys}" device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
backups: backups:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}" device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"

View File

@@ -1,4 +1,4 @@
# GerbilManager — Betriebsanleitung (TrueNAS) # GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung). > Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/) > Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
@@ -9,13 +9,12 @@
1. [Übersicht & Architektur](#1-übersicht--architektur) 1. [Übersicht & Architektur](#1-übersicht--architektur)
2. [Voraussetzungen](#2-voraussetzungen) 2. [Voraussetzungen](#2-voraussetzungen)
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas) 3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren) 4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung) 5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung) 6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions) 7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen) 8. [Fehlerbehebung](#8-fehlerbehebung)
9. [Fehlerbehebung](#9-fehlerbehebung)
--- ---
@@ -23,7 +22,7 @@
``` ```
Browser / Handy Browser / Handy
| HTTP :80 | HTTP :80 (oder PORT aus .env, z.B. 8080)
v v
┌──────────────────┐ ┌──────────────────┐
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy │ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
@@ -40,9 +39,10 @@ Browser / Handy
│ db (Postgres 17)│ │ backup (Sidecar) │ │ db (Postgres 17)│ │ backup (Sidecar) │
└──────────────────┘ │ pg_dump + tar + cron │ └──────────────────┘ │ pg_dump + tar + cron │
│ └──────────────────────┘ │ └──────────────────────┘
└─ pgdata-Volume (NAS-Dataset) └─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
photos-Volume (NAS-Dataset) photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
backups-Volume (NAS-Dataset) backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
``` ```
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`). **Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
| Was | Details | | Was | Details |
|-----|---------| |-----|---------|
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) | | TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` | | Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) | | Docker | bereits auf TrueNAS Goldeye vorhanden |
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) | | Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
--- ---
## 3. Erstinstallation auf TrueNAS ## 3. Erstinstallation auf TrueNAS Goldeye
### 3.1 ZFS-Datasets anlegen ### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
In TrueNAS → **Datasets****Dataset hinzufügen** (je einmal wiederholen): Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
| Dataset-Name | Empfohlener Pfad | Verwendung |
|---|---|---|
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
### 3.2 Repository klonen
```bash ```bash
# SSH in TrueNAS oder lokale Shell # Vier Ordner anlegen
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
```
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
### 3.2 Registry-Login auf der NAS
```bash
docker login git.rismer.de
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
```
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
### 3.3 Repository klonen
```bash
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
cd /opt/gerbilmanager cd /opt/gerbilmanager
``` ```
### 3.3 Konfiguration anlegen ### 3.4 Konfiguration anlegen
```bash ```bash
cp deploy/truenas/.env.example deploy/truenas/.env cp deploy/truenas/.env.example deploy/truenas/.env
# Jetzt .env bearbeiten:
nano deploy/truenas/.env nano deploy/truenas/.env
``` ```
Mindestens setzen: Mindestens setzen:
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
### 3.4 Images bauen und App starten | Variable | Wert |
|----------|------|
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
| `AI__ApiKey` | Dein Gemini API-Key |
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
### 3.5 Images ziehen und App starten
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml build docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen). Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen).
### 3.5 Prüfen > **TrueNAS Goldeye Custom App (Alternative):**
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
### 3.6 Verifikation
```bash ```bash
# Alle Container laufen? # Alle 4 Container laufen?
docker compose -f deploy/truenas/compose.yaml ps docker compose -f deploy/truenas/compose.yaml ps
# API-Healthcheck # API-Healthcheck (erwartet: {"status":"Healthy"})
curl http://localhost/api/health curl -s http://localhost/api/health
# Webapp im Browser # Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
http://<NAS-IP>/ curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
# API-Doku (Scalar) im Browser
http://<NAS-IP>/scalar
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
``` ```
--- ---
@@ -125,6 +164,7 @@ http://<NAS-IP>/
### Starten ### Starten
```bash ```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
docker compose -f deploy/truenas/compose.yaml down docker compose -f deploy/truenas/compose.yaml down
``` ```
### Aktualisieren (nach `git push` auf main) ### Aktualisieren (nach CI-Push auf main)
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
git pull git pull
docker compose -f deploy/truenas/compose.yaml build docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig. > EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
### Mit Gitea CI (wenn Actions aktiviert)
Push auf `main` triggert automatisch Build → Test → Image-Push.
Danach auf der NAS:
```bash
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d
```
--- ---
## 5. Backup & Wiederherstellung ## 5. Backup & Wiederherstellung
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
- Komprimiertes Foto-Archiv als `.tar.gz` - Komprimiertes Foto-Archiv als `.tar.gz`
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht - Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/` Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
``` ```
/mnt/SSD/gerbil/backups/ /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
2026-06-06_03-00/ 2026-06-06_03-00/
gerbilmanager_2026-06-06_03-00.sql (Datenbank) gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
photos_2026-06-06_03-00.tar.gz (Fotos) photos_2026-06-06_03-00.tar.gz (Fotos)
backup.log (Protokoll) backup.log (Protokoll)
``` ```
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
### Backup-Log prüfen ### Backup-Log prüfen
```bash ```bash
tail -50 /mnt/SSD/gerbil/backups/backup.log tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
``` ```
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
### Wiederherstellung — Runbook ### Wiederherstellung — Runbook
> **WARNUNG:** Alle aktuellen Daten werden überschrieben! > **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
**Schritt 1:** App stoppen (optional, aber empfohlen) **Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
```bash ```bash
docker compose -f deploy/truenas/compose.yaml stop api frontend docker compose -f deploy/truenas/compose.yaml stop api frontend
``` ```
**Schritt 2:** Restore ausführen **Schritt 2:** Restore ausführen
```bash
# Neuestes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh
# Bestimmtes Backup wiederherstellen: ```bash
docker compose -f deploy/truenas/compose.yaml exec backup \ # Neuestes Backup automatisch wählen und bestätigen:
/bin/sh /scripts/restore.sh 2026-06-05_03-00 docker compose -f deploy/truenas/compose.yaml exec -T backup \
/bin/sh /scripts/restore.sh latest -f
# Bestimmtes Backup (Datum aus Verzeichnisname):
docker compose -f deploy/truenas/compose.yaml exec -T backup \
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
``` ```
Das Skript:
1. Trennt alle offenen DB-Verbindungen
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
3. Entpackt das Foto-Archiv nach `/data/photos`
**Schritt 3:** API neu starten **Schritt 3:** API neu starten
```bash ```bash
docker compose -f deploy/truenas/compose.yaml start api frontend docker compose -f deploy/truenas/compose.yaml start api frontend
``` ```
**Schritt 4:** Prüfen **Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
```bash ```bash
curl http://localhost/api/color-varieties | grep -c '"id"' # Tier-Anzahl prüfen
# Erwarteter Wert: 73 curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
# Foto stichprobenartig prüfen
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
``` ```
### Restore-Nachweis (Round-Trip-Test) ### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09): Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
``` ```
73 ColorVarieties vorhanden 73 ColorVarieties vorhanden
→ DELETE 12 Zeilen → 61 verbleibend → DELETE 12 Zeilen → 61 verbleibend
→ pg_restore eingespielt → psql < dump.sql eingespielt
→ 73 ColorVarieties bestätigt → 73 ColorVarieties bestätigt
Exit-Code: 0 Exit-Code: 0
``` ```
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren. **Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
--- ---
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
### Empfohlene Snapshot-Konfiguration ### Empfohlene Snapshot-Konfiguration
In TrueNAS → **Datasets**Dataset auswählen**Snapshots****Regelmäßige Snapshots**: In TrueNAS → **Datasets**`JailStorage/DockerVolumes/gerbilmanager`**Snapshots****Regelmäßige Snapshots**:
| Dataset | Häufigkeit | Aufbewahrung | | Unterordner | Häufigkeit | Aufbewahrung |
|---------|-----------|--------------| |-------------|-----------|--------------|
| `gerbil/photos` | Stündlich | 24 Stunden | | `.../photos` | Stündlich | 24 Stunden |
| `gerbil/photos` | Täglich | 30 Tage | | `.../photos` | Täglich | 30 Tage |
| `gerbil/pgdata` | Stündlich | 24 Stunden | | `.../pgdata` | Stündlich | 24 Stunden |
| `gerbil/pgdata` | Täglich | 30 Tage | | `.../pgdata` | Täglich | 30 Tage |
| `gerbil/backups` | Täglich | 90 Tage | | `.../backups` | Täglich | 90 Tage |
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent, > **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden, > aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
### Snapshot manuell erstellen (z. B. vor Update) ### Snapshot manuell erstellen (z. B. vor Update)
```bash ```bash
# TrueNAS CLI # Pool-/Dataset-Name anpassen falls nötig
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d) zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d) zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
``` ```
### Aus ZFS-Snapshot wiederherstellen (Fotos) ### Aus ZFS-Snapshot wiederherstellen (Fotos)
```bash ```bash
# Snapshot auflisten # Snapshots auflisten
zfs list -t snapshot SSD/gerbil/photos zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
# Datei aus Snapshot kopieren # Einzelne Datei aus Snapshot kopieren
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/ cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
``` ```
--- ---
## 7. CI/CD via Gitea Actions ## 7. CI/CD via Gitea Actions
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**. CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
`git.rismer.de/gulum/gerbilmanager-frontend`.
### Aktivierung ### Registry-Secrets in Gitea
1. **Gitea Actions aktivieren:** Gitea → Repository → Einstellungen → Secrets:
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät): | Secret | Wert |
```bash |--------|------|
# Gitea Runner Container (einfachste Variante für TrueNAS) | `REGISTRY_USER` | Gitea-Benutzername |
docker run -d --name gitea-runner \ | `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/gitea-runner:/data \
-e GITEA_INSTANCE_URL=http://truenas:13000 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
gitea/act_runner:latest
```
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
3. **Registry-Secrets konfigurieren:** ### Update nach CI-Push
Gitea → Repository → Einstellungen → Secrets:
- `REGISTRY_USER` — dein Gitea-Benutzername
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
### Workflow nach Aktivierung ```bash
# Auf der NAS nach erfolgreichem CI-Lauf:
``` cd /opt/gerbilmanager
git push origin main git pull
→ Gitea Actions: dotnet test + npm test + npm run build docker compose -f deploy/truenas/compose.yaml pull
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/ docker compose -f deploy/truenas/compose.yaml up -d
→ Auf NAS: docker compose pull + up -d
``` ```
--- ---
## 8. Offene Fragen ## 8. Fehlerbehebung
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
| # | Frage | Auswirkung |
|---|-------|-----------|
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
---
## 9. Fehlerbehebung
### App startet nicht ### App startet nicht
```bash ```bash
# Logs aller Container
docker compose -f deploy/truenas/compose.yaml logs docker compose -f deploy/truenas/compose.yaml logs
# Logs eines bestimmten Containers
docker compose -f deploy/truenas/compose.yaml logs api docker compose -f deploy/truenas/compose.yaml logs api
docker compose -f deploy/truenas/compose.yaml logs db docker compose -f deploy/truenas/compose.yaml logs db
``` ```
### Port 80 belegt
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
```bash
# In deploy/truenas/.env:
PORT=8080
# Dann neu starten:
docker compose -f deploy/truenas/compose.yaml up -d
```
### Postgres startet nicht (Permission denied auf pgdata)
```bash
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
docker compose -f deploy/truenas/compose.yaml restart db
```
### Registry-Pull schlägt fehl
```bash
# Neu einloggen:
docker login git.rismer.de
# Dann pull wiederholen:
docker compose -f deploy/truenas/compose.yaml pull
```
### Datenbank nicht erreichbar ### Datenbank nicht erreichbar
```bash ```bash
# DB-Container läuft?
docker compose -f deploy/truenas/compose.yaml ps db docker compose -f deploy/truenas/compose.yaml ps db
# Verbindung testen
docker compose -f deploy/truenas/compose.yaml exec db \ docker compose -f deploy/truenas/compose.yaml exec db \
psql -U postgres -d gerbilmanager -c "\dt" psql -U postgres -d gerbilmanager -c "\dt"
``` ```
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
### Backup-Fehler ### Backup-Fehler
```bash ```bash
# Backup-Log prüfen tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
cat /mnt/SSD/gerbil/backups/backup.log | tail -30 docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
# Backup manuell starten (mit Fehlerausgabe)
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/backup.sh
``` ```
### Fotos werden nicht angezeigt ### Fotos werden nicht angezeigt
Prüfe ob das `photos`-Volume korrekt gemounted ist:
```bash ```bash
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
``` ```
### Container-Status zurücksetzen (Neustart)
```bash
docker compose -f deploy/truenas/compose.yaml restart api
```
### Kompletter Neustart (Daten bleiben erhalten) ### Kompletter Neustart (Daten bleiben erhalten)
```bash ```bash

View File

@@ -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')
}) })
}) })

View File

@@ -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
} }
] ]

View File

@@ -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])

View File

@@ -27,7 +27,7 @@ import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types' import type { Gender, Gerbil } 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,
@@ -512,7 +512,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>
) )
} }

View File

@@ -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} />
) : ( ) : (