Compare commits
6 Commits
feature/st
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
| 2198c33898 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b |
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,16 +7,16 @@ POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
|
||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
||||
REGISTRY=truenas:13000/gulum
|
||||
# Container Registry (git.rismer.de/gulum)
|
||||
REGISTRY=git.rismer.de/gulum
|
||||
TAG=latest
|
||||
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
|
||||
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
# 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_KEEP_DAYS=7
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
|
||||
# --- .NET API (GerbilManagerWebAPI) ---
|
||||
api:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
|
||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||
frontend:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
@@ -95,19 +95,19 @@ services:
|
||||
|
||||
volumes:
|
||||
# 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:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
||||
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
|
||||
photos:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
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.
|
||||
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||
keys:
|
||||
@@ -115,10 +115,10 @@ volumes:
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
|
||||
|
||||
327
docs/ops.md
327
docs/ops.md
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||
@@ -9,13 +9,12 @@
|
||||
|
||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||
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)
|
||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
||||
8. [Fehlerbehebung](#8-fehlerbehebung)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@
|
||||
|
||||
```
|
||||
Browser / Handy
|
||||
| HTTP :80
|
||||
| HTTP :80 (oder PORT aus .env, z.B. 8080)
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||
@@ -40,9 +39,10 @@ Browser / Handy
|
||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||
└──────────────────┘ │ pg_dump + tar + cron │
|
||||
│ └──────────────────────┘
|
||||
└─ pgdata-Volume (NAS-Dataset)
|
||||
photos-Volume (NAS-Dataset)
|
||||
backups-Volume (NAS-Dataset)
|
||||
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
```
|
||||
|
||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
|
||||
|
||||
| Was | Details |
|
||||
|-----|---------|
|
||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
||||
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
|
||||
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
|
||||
| Docker | bereits auf TrueNAS Goldeye vorhanden |
|
||||
| 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):
|
||||
|
||||
| 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
|
||||
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
|
||||
|
||||
```bash
|
||||
# SSH in TrueNAS oder lokale Shell
|
||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
||||
# Vier Ordner anlegen
|
||||
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
|
||||
```
|
||||
|
||||
### 3.3 Konfiguration anlegen
|
||||
### 3.4 Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||
# Jetzt .env bearbeiten:
|
||||
nano deploy/truenas/.env
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Erster Start dauert ca. 2–3 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
|
||||
# Alle Container laufen?
|
||||
# Alle 4 Container laufen?
|
||||
docker compose -f deploy/truenas/compose.yaml ps
|
||||
|
||||
# API-Healthcheck
|
||||
curl http://localhost/api/health
|
||||
# API-Healthcheck (erwartet: {"status":"Healthy"})
|
||||
curl -s http://localhost/api/health
|
||||
|
||||
# Webapp im Browser
|
||||
http://<NAS-IP>/
|
||||
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
|
||||
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
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
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
|
||||
```
|
||||
|
||||
### Aktualisieren (nach `git push` auf main)
|
||||
### Aktualisieren (nach CI-Push auf main)
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
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
|
||||
```
|
||||
|
||||
> 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
|
||||
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
|
||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||
- 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/
|
||||
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)
|
||||
backup.log (Protokoll)
|
||||
```
|
||||
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
|
||||
### Backup-Log prüfen
|
||||
|
||||
```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
|
||||
|
||||
> **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
|
||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||
```
|
||||
|
||||
**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:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
||||
```bash
|
||||
# Neuestes Backup automatisch wählen und bestätigen:
|
||||
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
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||
```
|
||||
|
||||
**Schritt 4:** Prüfen
|
||||
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
|
||||
```bash
|
||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
||||
# Erwarteter Wert: 73
|
||||
# Tier-Anzahl prüfen
|
||||
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
|
||||
→ DELETE 12 Zeilen → 61 verbleibend
|
||||
→ pg_restore eingespielt
|
||||
→ psql < dump.sql eingespielt
|
||||
→ 73 ColorVarieties bestätigt
|
||||
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
|
||||
|
||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
|
||||
| Dataset | Häufigkeit | Aufbewahrung |
|
||||
|---------|-----------|--------------|
|
||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
||||
| `gerbil/photos` | Täglich | 30 Tage |
|
||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
||||
| `gerbil/backups` | Täglich | 90 Tage |
|
||||
| Unterordner | Häufigkeit | Aufbewahrung |
|
||||
|-------------|-----------|--------------|
|
||||
| `.../photos` | Stündlich | 24 Stunden |
|
||||
| `.../photos` | Täglich | 30 Tage |
|
||||
| `.../pgdata` | Stündlich | 24 Stunden |
|
||||
| `.../pgdata` | Täglich | 30 Tage |
|
||||
| `.../backups` | Täglich | 90 Tage |
|
||||
|
||||
> **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,
|
||||
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
|
||||
### Snapshot manuell erstellen (z. B. vor Update)
|
||||
|
||||
```bash
|
||||
# TrueNAS CLI
|
||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
||||
# Pool-/Dataset-Name anpassen falls nötig
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||
|
||||
```bash
|
||||
# Snapshot auflisten
|
||||
zfs list -t snapshot SSD/gerbil/photos
|
||||
# Snapshots auflisten
|
||||
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
|
||||
# Datei aus Snapshot kopieren
|
||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
||||
# Einzelne Datei aus Snapshot kopieren
|
||||
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
|
||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
||||
```bash
|
||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
||||
docker run -d --name gitea-runner \
|
||||
-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"
|
||||
| Secret | Wert |
|
||||
|--------|------|
|
||||
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||
|
||||
3. **Registry-Secrets konfigurieren:**
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
||||
### Update nach CI-Push
|
||||
|
||||
### Workflow nach Aktivierung
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
||||
→ Auf NAS: docker compose pull + up -d
|
||||
```bash
|
||||
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
|
||||
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
|
||||
## 8. Fehlerbehebung
|
||||
|
||||
### App startet nicht
|
||||
|
||||
```bash
|
||||
# Logs aller Container
|
||||
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 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
|
||||
|
||||
```bash
|
||||
# DB-Container läuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps db
|
||||
|
||||
# Verbindung testen
|
||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
psql -U postgres -d gerbilmanager -c "\dt"
|
||||
```
|
||||
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
### Backup-Fehler
|
||||
|
||||
```bash
|
||||
# Backup-Log prüfen
|
||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
||||
|
||||
# Backup manuell starten (mit Fehlerausgabe)
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/backup.sh
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Fotos werden nicht angezeigt
|
||||
|
||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
||||
```bash
|
||||
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)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -223,8 +223,9 @@ describe('Farbschlag catalog', () => {
|
||||
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', () => {
|
||||
@@ -285,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')
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
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',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -330,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', () => {
|
||||
@@ -363,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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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]', () => {
|
||||
// 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',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
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', '?'])
|
||||
@@ -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.
|
||||
})
|
||||
|
||||
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'])
|
||||
@@ -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-', () => {
|
||||
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-', () => {
|
||||
@@ -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.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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,425 +2,425 @@
|
||||
{
|
||||
"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} />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user