Compare commits

..

1 Commits

Author SHA1 Message Date
fa44dc1f6c AR-5: Committed Katalog-Generator + Drift-Guard + Backend-Artefakt
- gen-seed.mts: deterministischer Generator aus catalog.ts (Single Source);
  emittiert BEIDE Artefakte: generated.json (Klammer-Notation, Display) +
  backend.json (frozen ef/cchm/ch, für Pam EF-Migrationen). Kein magisches
  Artefakt mehr.
- package.json: npm run gen:catalog (npx tsx gen-seed.mts)
- catalog-drift.test.ts: Drift-Guard — liest generated.json von Disk via
  import.meta.url + readFileSync, vergleicht mit live CATALOG; Fail-Meldung
  zeigt 'npm run gen:catalog'. 5 Test-Files, 93 Tests grün.
- colorVarietySeed.backend.json: 66 Zeilen frozen symbols (ef/cchm/ch),
  kein sofortiger Backend-Eingriff (AR-5 Guardrail; Pam konsumiert bei
  nächster Reseed-Migration).
- README.md: Katalog-Generator-Doku (wann laufen, was erzeugt wird).
Gate: build ✓  eslint ✓  vitest 93/93 ✓
2026-06-06 19:47:27 +02:00
122 changed files with 1126 additions and 18819 deletions

View File

@@ -9,10 +9,10 @@
# 2. npm test + npm run build (Frontend)
# 3. Docker-Images bauen und in die Gitea-Registry pushen
#
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
# Registry: truenas:13000 (internes Gitea Container Registry)
# Images:
# git.rismer.de/gulum/gerbilmanager-api:latest
# git.rismer.de/gulum/gerbilmanager-frontend:latest
# truenas:13000/gulum/gerbilmanager-api:latest
# truenas:13000/gulum/gerbilmanager-frontend:latest
name: CI
@@ -25,7 +25,7 @@ on:
- main
env:
REGISTRY: git.rismer.de
REGISTRY: truenas:13000
REGISTRY_OWNER: gulum
DOTNET_VERSION: "10.0.x"
NODE_VERSION: "22"
@@ -49,14 +49,7 @@ jobs:
run: dotnet build GerbilManager.slnx --no-restore -c Release
- name: dotnet-ef Tool installieren
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
run: |
dotnet tool update --global dotnet-ef --version 10.0.*
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
run: dotnet tool install --global dotnet-ef --version 10.0.*
- name: DB-3 EF Migrations Drift-Check
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
@@ -66,7 +59,7 @@ jobs:
dotnet ef migrations has-pending-model-changes
--project GerbilManagerWebAPI
--startup-project GerbilManagerWebAPI
--no-build --configuration Release
--no-build -c Release
- name: Tests ausfuehren
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"

3
.gitignore vendored
View File

@@ -137,6 +137,3 @@ GerbilManagerWebAPI/photo-storage/
# AR-3: Data Protection key ring (dev-only ephemeral keys) — never commit
GerbilManagerWebAPI/.data-protection-keys/
# node_modules — never commit, installed locally per npm install
node_modules/

View File

@@ -1,103 +0,0 @@
using GerbilManagerWebAPI.Endpoints;
namespace GerbilManager.Tests;
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
public class CmsPublishTests
{
private static string TempRoot() =>
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
[Fact]
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
{
var root = TempRoot();
try
{
var files = new Dictionary<string, string>
{
["index.html"] = "<html>start</html>",
["kontakt/index.html"] = "<html>kontakt</html>",
["assets/site.css"] = "body {}",
};
await CmsEndpoints.PublishToDirectoryAsync(files, root);
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
Assert.Equal("<html>start</html>",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
Assert.Equal("version-2", content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
{
var root = TempRoot();
try
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = "x" }, root);
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_mehrfach_ohne_Fehler()
{
var root = TempRoot();
try
{
for (int i = 1; i <= 3; i++)
{
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
}
Assert.Equal("v3",
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
{
var root = TempRoot();
try
{
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
await CmsEndpoints.PublishToDirectoryAsync(
new Dictionary<string, string> { ["index.html"] = german }, root);
var content = await File.ReadAllTextAsync(
Path.Combine(root, "live", "index.html"),
System.Text.Encoding.UTF8);
Assert.Equal(german, content);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}

View File

@@ -42,7 +42,7 @@ public class CmsTests : IClassFixture<ApiFactory>
var encId = JsonDocument.Parse(await encResp.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
await _client.PostAsJsonAsync("/gerbils", new { name = "Verkaufsmaus", gender = "female", status = "ForSale", enclosureId = encId });
// a non-ForSale gerbil must NOT appear
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Active" });
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()

View File

@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
Id = Guid.NewGuid(),
Name = "Krümel",
Gender = Gender.female,
Status = GerbilStatus.Breeding,
Status = GerbilStatus.Active,
DateOfBirth = new DateOnly(2025, 3, 12),
LitterId = litter.Id,
EnclosureId = enclosure.Id,
@@ -118,7 +118,7 @@ namespace GerbilManager.Tests
var row = lines[1];
Assert.Contains("Krümel", row);
Assert.Contains("Weiblich", row);
Assert.Contains("Zucht", row);
Assert.Contains("Aktiv", row);
Assert.Contains("12.03.2025", row);
Assert.Contains("Großbecken", row);
Assert.Contains("Wurf K", row);

View File

@@ -1,83 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests;
/// <summary>profilePhotoUrl: computed field on GerbilDto — first photo by sortOrder, or null.</summary>
public class GerbilProfilePhotoTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
private readonly ApiFactory _factory;
public GerbilProfilePhotoTests(ApiFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
private static Guid ExtractId(string json) =>
Guid.Parse(Regex.Match(json, "\"id\":\"([^\"]+)\"").Groups[1].Value);
private static string? GetStr(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private async Task<Guid> CreateGerbil(string name)
{
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name, gender = "female" }));
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
return ExtractId(await resp.Content.ReadAsStringAsync());
}
private void InsertPhotos(Guid gerbilId, params (string fileName, int sortOrder)[] photos)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
foreach (var (fn, so) in photos)
db.GerbilPhotos.Add(new GerbilPhoto
{
Id = Guid.NewGuid(), GerbilId = gerbilId,
FileName = fn, SortOrder = so, CreatedAt = DateTimeOffset.UtcNow,
});
db.SaveChanges();
}
[Fact]
public async Task GetById_without_photos_returns_null_profilePhotoUrl()
{
var id = await CreateGerbil("NoPhoto");
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.True(el.TryGetProperty("profilePhotoUrl", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task GetById_with_photos_returns_lowest_sortOrder_url()
{
var id = await CreateGerbil("PhotoGerbil");
InsertPhotos(id, ("second.jpg", 1), ("first.jpg", 0));
var json = await _client.GetStringAsync($"/gerbils/{id}");
var el = JsonDocument.Parse(json).RootElement;
Assert.Equal("/photos/files/first.jpg", GetStr(el, "profilePhotoUrl"));
}
[Fact]
public async Task GetList_returns_profilePhotoUrl_without_n_plus_1()
{
var id = await CreateGerbil("ListPhoto");
InsertPhotos(id, ("list-photo.jpg", 0));
var json = await _client.GetStringAsync("/gerbils");
var root = JsonDocument.Parse(json).RootElement;
var items = root.GetProperty("items").EnumerateArray().ToList();
var gerbil = items.FirstOrDefault(x => GetStr(x, "id") == id.ToString());
Assert.NotEqual(default, gerbil);
Assert.Equal("/photos/files/list-photo.jpg", GetStr(gerbil, "profilePhotoUrl"));
}
}

View File

@@ -1,58 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using GerbilManagerWebAPI.Models;
namespace GerbilManager.Tests;
/// <summary>SEARCH-1: separator-insensitive name search + OriginBreeder (Herkunft) filter.</summary>
public class GerbilSearchTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public GerbilSearchTests(ApiFactory factory) => _client = factory.CreateClient();
private static HttpContent Gerbil(string name, string? originBreeder = null) =>
JsonContent.Create(new
{
name,
gender = "female",
originBreeder,
});
[Theory]
[InlineData("Clan-Kleine-Chaoten", "clankleinechaoten")]
[InlineData("Clan Kleine Chaoten", "clankleinechaoten")]
[InlineData("v.d. Kleinen_Chaoten", "vdkleinenchaoten")]
public void Normalize_strips_separators_and_lowercases(string input, string expected) =>
Assert.Equal(expected, GerbilSearch.Normalize(input));
[Fact]
public async Task NameSearch_filter_matches_across_separators()
{
var create = await _client.PostAsync("/gerbils", Gerbil("Clan-Kleine-Chaoten"));
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
// Gridify contains is "=*value" (no trailing '*', per the main hotfix 0ee2b53).
// The client normalizes the user's term the same way the column is normalized.
var json = await _client.GetStringAsync("/gerbils?filter=nameSearch=*clankleinechaoten");
Assert.Contains("Clan-Kleine-Chaoten", json);
// a term that doesn't normalize-match must NOT return it
var miss = await _client.GetStringAsync("/gerbils?filter=nameSearch=*completelyother");
Assert.DoesNotContain("Clan-Kleine-Chaoten", miss);
}
[Fact]
public async Task OriginBreeder_is_filterable_and_listed_in_breeders_endpoint()
{
await _client.PostAsync("/gerbils", Gerbil("Herkunftstier", originBreeder: "Zucht der kleinen Chaoten"));
// distinct-values dropdown source
var breeders = await _client.GetStringAsync("/gerbils/breeders");
Assert.Contains("Zucht der kleinen Chaoten", breeders);
// Gridify filter on the field
var filtered = await _client.GetStringAsync("/gerbils?filter=originBreeder==Zucht der kleinen Chaoten");
Assert.Contains("Herkunftstier", filtered);
}
}

View File

@@ -1,122 +0,0 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
namespace GerbilManager.Tests;
/// <summary>STATUS-MODEL: GerbilStatusService.Derive() precedence rules.</summary>
public class GerbilStatusTests
{
private static readonly DateOnly Today = new(2026, 6, 7);
// 1) DateOfDeath → Deceased, always overrides user input
[Fact]
public void DateOfDeath_set_returns_Deceased()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// DateOfDeath wins even when also Abgegeben
[Fact]
public void DateOfDeath_wins_over_Abgegeben()
{
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, new DateOnly(2020, 1, 1), new DateOnly(2024, 3, 4),
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// 2) Abgegeben without death date → GivenAway
[Fact]
public void ReceiverContact_set_returns_GivenAway()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2022, 1, 1), dateOfDeath: null,
isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan7y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Deceased, status);
}
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
[Fact]
public void ExactlyMaxAge_is_not_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
// One day before threshold is alive
[Fact]
public void OneDayBeforeMaxAge_is_alive()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(1);
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-8);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 4) Default → Breeding
[Fact]
public void No_special_condition_returns_Breeding()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
// 4a) User sets Pet → kept
[Fact]
public void User_Pet_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Pet, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Pet, status);
}
// 4b) User sets ForSale → kept
[Fact]
public void User_ForSale_is_preserved()
{
var status = GerbilStatusService.Derive(
GerbilStatus.ForSale, new DateOnly(2024, 1, 1), dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.ForSale, status);
}
// DateOfBirth null → age rule does not fire
[Fact]
public void Null_DateOfBirth_does_not_trigger_age_rule()
{
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dateOfBirth: null, dateOfDeath: null,
isAbgegeben: false, Today);
Assert.Equal(GerbilStatus.Breeding, status);
}
}

View File

@@ -1,343 +0,0 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace GerbilManager.Tests
{
/// <summary>
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
/// </summary>
public class ImportDocxServiceTests : IDisposable
{
private readonly string _dir;
public ImportDocxServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
// One open connection keeps the :memory: SQLite DB alive across calls.
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
{
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
return (db, conn);
}
private void WriteLitters(object litters) =>
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
System.Text.Json.JsonSerializer.Serialize(litters));
private void WriteAnimals(object animals) =>
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
System.Text.Json.JsonSerializer.Serialize(animals));
// ── Test 1: dry-run shows correct counts without writing ─────────────────
[Fact]
public async Task DryRun_counts_new_animal_without_writing()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
Assert.False(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
Assert.Equal(1, report.GoHomeFilled);
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
}
}
// ── Test 2: execute creates animal with all fields + FK constraints ───────
[Fact]
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
var litter = new Litter
{
Id = Guid.NewGuid(),
Name = "Testwurf",
Date = new DateOnly(2023, 5, 1),
};
db.Litters.Add(litter);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
owner = "Max Mustermann", abgabeDate = "01.07.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, report.ContactsCreated);
var gerbil = await db.Gerbils.SingleAsync();
Assert.Equal("Pepper", gerbil.Name);
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
Assert.Equal(Gender.female, gerbil.Gender);
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
Assert.False(gerbil.IsResident);
Assert.Equal("docx", gerbil.ImportSource);
Assert.StartsWith("docx-", gerbil.ExternalRef);
var contact = await db.Contacts.SingleAsync();
Assert.Equal("Max Mustermann", contact.Name);
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
}
}
// ── Test 3: idempotency — second run creates zero ────────────────────────
[Fact]
public async Task Execute_is_idempotent_second_run_creates_zero()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
owner = "", abgabeDate = "01.05.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(1, first.Created);
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
}
}
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
[Fact]
public async Task Execute_enriches_existing_animal_does_not_duplicate()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
var existing = new Gerbil
{
Id = Guid.NewGuid(),
Name = "Eddie",
DateOfBirth = new DateOnly(2022, 8, 10),
Gender = Gender.male,
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
IsResident = false,
};
db.Gerbils.Add(existing);
await db.SaveChangesAsync();
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
// Same name+dob → enrich, not create
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
deathDate = "", deathCause = "", farbschlag = "" }
});
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
// GoHomeDate was enriched
var updated = await db.Gerbils.SingleAsync();
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
}
}
// ── Test 5: Deceased status when deathDate set ───────────────────────────
[Fact]
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
{
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
owner = "", abgabeDate = "",
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
});
await new ImportDocxService(db, _dir).RunAsync(execute: true);
var gerbil = await db.Gerbils.SingleAsync();
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
Assert.Equal("Tumor", gerbil.CauseOfDeath);
}
}
// ── Test 7: P0 REGRESSION — execute works under a retrying execution strategy ──
[Fact]
public async Task Execute_works_under_retrying_execution_strategy()
{
// Regression: NpgsqlRetryingExecutionStrategy (MaxRetryCount>0) calls
// OnFirstExecution() at the start of ExecuteAsync, which throws
// InvalidOperationException when it detects a user-initiated transaction
// that was NOT opened through the strategy. This test wires the same check
// (via FakeRetryingStrategy, MaxRetryCount=1) so the bug would surface in CI
// without a live Npgsql instance.
//
// With the BUG (direct BeginTransactionAsync before strategy.ExecuteAsync):
// → OnFirstExecution sees active user tx → InvalidOperationException
// With the FIX (BeginTransactionAsync inside strategy.ExecuteAsync lambda):
// → OnFirstExecution: no tx yet → OK
var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseSqlite(conn)
.ReplaceService<IExecutionStrategyFactory, FakeRetryingStrategyFactory>()
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "3/3", litterDob = "01.01.2023", name = "Pixie", gender = "female",
owner = "Retry Adopter", abgabeDate = "01.03.2023",
deathDate = "", deathCause = "", farbschlag = "" }
});
// Must NOT throw InvalidOperationException (user-initiated tx rejected)
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.True(report.Executed);
Assert.Equal(1, report.Created);
Assert.Equal(1, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.Contacts.CountAsync());
}
}
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
[Fact]
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
{
// P0 regression: two animals in the same litter with the same normalized name
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
// this test an authoritative regression gate.
var (db, conn) = NewSqliteDb();
await using (conn)
await using (db)
{
WriteLitters(Array.Empty<object>());
WriteAnimals(new[]
{
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Eva Müller", abgabeDate = "10.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
// Identical name+wsCode+litterDob → base ExternalRef collision
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
owner = "Lena Braun", abgabeDate = "11.08.2022",
deathDate = "", deathCause = "", farbschlag = "" },
});
// Must NOT throw unique-key violation
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(2, report.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
// Both start with the docx- prefix
Assert.All(refs, r => Assert.StartsWith("docx-", r));
// Must be distinct (unique index enforces this in SQLite)
Assert.Equal(2, refs.Distinct().Count());
// Second occurrence gets the -2 suffix
Assert.Single(refs, r => r.EndsWith("-2"));
// Idempotent re-run: zero new, still 2 in DB
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Created);
Assert.Equal(2, await db.Gerbils.CountAsync());
}
}
}
// ── Helpers for Test 7 ────────────────────────────────────────────────────────
/// <summary>
/// Execution strategy with MaxRetryCount=1 so that EF Core's base
/// OnFirstExecution() throws when it detects a user-initiated transaction
/// that was not opened through CreateExecutionStrategy().ExecuteAsync().
/// ShouldRetryOn=false → no actual retry; the check alone is what we need.
/// </summary>
internal sealed class FakeRetryingStrategy(ExecutionStrategyDependencies deps)
: ExecutionStrategy(deps, maxRetryCount: 1, maxRetryDelay: TimeSpan.Zero)
{
protected override bool ShouldRetryOn(Exception exception) => false;
}
internal sealed class FakeRetryingStrategyFactory(ExecutionStrategyDependencies deps)
: IExecutionStrategyFactory
{
public IExecutionStrategy Create() => new FakeRetryingStrategy(deps);
}
}

View File

@@ -103,31 +103,6 @@ namespace GerbilManager.Tests
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact]
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
{
// DB-5: litters must have ExternalRef set to the source litter id on first import,
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
using var db = NewDb();
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
// ExternalRef is set on created litters
var litters = await db.Litters.ToListAsync();
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
Assert.Contains(litters, l => l.ExternalRef == "L1");
Assert.Contains(litters, l => l.ExternalRef == "L2");
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
foreach (var l in litters) l.Name = "Geänderter Name";
await db.SaveChangesAsync();
// Re-import: litters detected as existing via ExternalRef even though Name changed
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
Assert.Equal(0, second.Litters.Created);
Assert.Equal(2, await db.Litters.CountAsync());
}
[Fact]
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
{
@@ -261,54 +236,6 @@ namespace GerbilManager.Tests
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
{
// Regression for "Unable to save changes because a circular dependency was detected":
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
// Triggered when a gerbil is both parent (in one synth litter) and offspring
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
using var conn = new SqliteConnection("DataSource=:memory:");
conn.Open();
try
{
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
]
""");
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
using var db = new ApplicationContext(opts);
await db.Database.EnsureCreatedAsync();
// must NOT throw InvalidOperationException (circular dependency)
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, await db.Gerbils.CountAsync());
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
// both should be litter-linked
Assert.NotNull(alpha.LitterId);
Assert.NotNull(beta.LitterId);
// deferred FK update must have set the litter parents correctly
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
Assert.Equal(beta.Id, alphaLitter.MotherId);
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
Assert.Equal(alpha.Id, betaLitter.MotherId);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Fact]
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
{
@@ -621,32 +548,27 @@ namespace GerbilManager.Tests
[Fact]
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
{
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
// SEED-HELL bounce regression: the 61 existing entries must NOT change their
// Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
// A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
// still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
Assert.NotNull(cpFuchs);
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
// Existing CP-*-Hell entries (IDs 62-66) unchanged
// New entries at IDs 62-66 exist with correct names
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
// Total count is exactly 70
Assert.Equal(70, db.ColorVarieties.Count());
// Total count is exactly 66
Assert.Equal(66, db.ColorVarieties.Count());
}
[Theory]

View File

@@ -1,181 +0,0 @@
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
namespace GerbilManager.Tests
{
public class IngestResolvedServiceTests : IDisposable
{
private readonly string _dir;
private readonly string _resolvedJsonPath;
public IngestResolvedServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), "ingest-resolved-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
_resolvedJsonPath = Path.Combine(_dir, "resolved_import.json");
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var photoId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new
{
Id = contactId,
Name = "Test Breeder",
ZuchtName = "Test Zucht",
City = "Test City",
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = ""
}
},
Litters = new[]
{
new
{
Id = litterId,
Name = "Wurf A",
Date = "2026-01-01",
TotalBorn = 5,
DeathsWithin8Weeks = 0,
FatherId = fatherId,
MotherId = motherId,
ExpectedGoHomeDate = (string?)null,
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A"
}
},
Gerbils = new[]
{
new
{
Id = fatherId,
Name = "Papa",
Gender = "male",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-papa",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "papa",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
},
new
{
Id = motherId,
Name = "Mama",
Gender = "female",
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"), // Schwarz
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-mama",
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = "mama",
CharacterTraits = new string[] {},
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true
}
},
GerbilPhotos = new[]
{
new
{
Id = photoId,
GerbilId = fatherId,
FileName = "photo1.jpg",
SortOrder = 0
}
}
};
File.WriteAllText(_resolvedJsonPath, JsonSerializer.Serialize(data));
}
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
private ApplicationContext NewDb()
{
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("ingest-resolved-" + Guid.NewGuid().ToString("N"))
.Options;
var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
return db;
}
[Fact]
public async Task IngestResolved_loads_data_and_resolves_all_relations()
{
using var db = NewDb();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Import:SourcePath", _dir }
})
.Build();
var service = new IngestResolvedService(db, config, null!);
var result = await service.RunAsync();
Assert.Contains("Ingestion successful!", result);
// Verify counts
Assert.Equal(1, await db.Contacts.CountAsync());
Assert.Equal(1, await db.Litters.CountAsync());
Assert.Equal(2, await db.Gerbils.CountAsync());
Assert.Equal(1, await db.GerbilPhotos.CountAsync());
// Verify relations
var litter = await db.Litters.SingleAsync();
var father = await db.Gerbils.SingleAsync(g => g.Name == "Papa");
var mother = await db.Gerbils.SingleAsync(g => g.Name == "Mama");
var photo = await db.GerbilPhotos.SingleAsync();
Assert.Equal(father.Id, litter.FatherId);
Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId);
}
}
}

View File

@@ -1,88 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace GerbilManager.Tests;
/// <summary>LITTER-MORTALITY: deathsWithin8Weeks field — round-trip, validation, null default.</summary>
public class LitterMortalityTests : IClassFixture<ApiFactory>
{
private readonly HttpClient _client;
public LitterMortalityTests(ApiFactory factory) => _client = factory.CreateClient();
private static int? GetInt(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt32() : null;
private async Task<JsonElement> CreateAndGet(object body)
{
var post = await _client.PostAsJsonAsync("/litters", body);
post.EnsureSuccessStatusCode();
var idStr = JsonDocument.Parse(await post.Content.ReadAsStringAsync()).RootElement
.GetProperty("id").GetString()!;
return JsonDocument.Parse(await _client.GetStringAsync($"/litters/{idStr}")).RootElement;
}
[Fact]
public async Task DeathsWithin8Weeks_round_trips()
{
var el = await CreateAndGet(new
{
name = "Wurf Mortalitätstest",
date = "2026-05-01",
totalBorn = 6,
deathsWithin8Weeks = 2,
});
Assert.Equal(6, GetInt(el, "totalBorn"));
Assert.Equal(2, GetInt(el, "deathsWithin8Weeks"));
}
[Fact]
public async Task DeathsWithin8Weeks_defaults_null()
{
var el = await CreateAndGet(new { name = "Wurf Kein Mort", date = "2026-05-02" });
Assert.True(el.TryGetProperty("deathsWithin8Weeks", out var v));
Assert.Equal(JsonValueKind.Null, v.ValueKind);
}
[Fact]
public async Task DeathsWithin8Weeks_exceeding_TotalBorn_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Ungültig",
date = "2026-05-03",
totalBorn = 4,
deathsWithin8Weeks = 5,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("TotalBorn", body);
}
[Fact]
public async Task Negative_DeathsWithin8Weeks_returns_400()
{
var resp = await _client.PostAsJsonAsync("/litters", new
{
name = "Negativ",
date = "2026-05-04",
deathsWithin8Weeks = -1,
});
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
var body = await resp.Content.ReadAsStringAsync();
Assert.Contains("negativ", body);
}
[Fact]
public async Task DeathsWithin8Weeks_allowed_when_TotalBorn_null()
{
var el = await CreateAndGet(new
{
name = "Kein TotalBorn",
date = "2026-05-05",
deathsWithin8Weeks = 3,
});
Assert.Equal(3, GetInt(el, "deathsWithin8Weeks"));
}
}

View File

@@ -1,280 +0,0 @@
using System.Net;
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Names;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManager.Tests
{
/// <summary>
/// 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
{
// ── Prompt assembly ───────────────────────────────────────────────────
[Fact]
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("reinen JSON-Array", prompt);
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
Assert.Contains("name", prompt);
Assert.Contains("meaning", 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]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt);
// codes are mapped to German descriptions
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Griechische Mythologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
Assert.Contains("5", prompt);
Assert.DoesNotContain("Buchstaben", prompt);
Assert.DoesNotContain("weibliche", prompt);
Assert.DoesNotContain("männliche", prompt);
}
[Fact]
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
Assert.DoesNotContain("weibliche", 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 ──────────────────────────────────────────────────────
[Fact]
public void ParseSuggestions_verarbeitet_reines_JSON()
{
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
var result = NameSuggestionService.ParseSuggestions(json);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("Astrid", result[0].Name);
Assert.Equal("göttliche Stärke", result[0].Meaning);
Assert.Equal("Altnordisch", result[0].Origin);
}
[Fact]
public void ParseSuggestions_strippt_json_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Aiko", result![0].Name);
}
[Fact]
public void ParseSuggestions_strippt_generische_Markdown_Fence()
{
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var result = NameSuggestionService.ParseSuggestions(fenced);
Assert.NotNull(result);
Assert.Equal("Luna", result![0].Name);
}
[Fact]
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
{
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
var result = NameSuggestionService.ParseSuggestions(messy);
Assert.NotNull(result);
Assert.Equal("Sol", result![0].Name);
}
[Fact]
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
{
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
Assert.Null(NameSuggestionService.ParseSuggestions(""));
}
// ── 503: nicht konfiguriert ───────────────────────────────────────────
[Theory]
[InlineData(null, "key", "model")]
[InlineData("https://api.example.com/v1", null, "model")]
[InlineData("https://api.example.com/v1", "key", null)]
[InlineData(null, null, null)]
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
string? baseUrl, string? apiKey, string? model)
{
var service = CreateService(baseUrl, apiKey, model,
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
var result = await service.SuggestAsync(null, null, null, 5);
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
Assert.Null(result.Suggestions);
}
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
[Fact]
public async Task SuggestAsync_parst_valide_JSON_Antwort()
{
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal(2, result.Suggestions!.Count);
Assert.Equal("Astrid", result.Suggestions[0].Name);
}
[Fact]
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
{
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
var handler = new StubHandler(_ => Canned(fenced));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.Equal("Luna", result.Suggestions![0].Name);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
{
var handler = new StubHandler(_ => Canned("das ist kein json"));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
Assert.Null(result.Suggestions);
}
[Fact]
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
{
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync(null, null, null, 3);
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
}
// ── Helfer ────────────────────────────────────────────────────────────
private static NameSuggestionService CreateService(
string? baseUrl, string? apiKey, string? model, StubHandler handler)
{
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
return new NameSuggestionService(new HttpClient(handler), options);
}
private static HttpResponseMessage Canned(string content)
{
var completion = new
{
choices = new[] { new { message = new { role = "assistant", content } } },
};
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(completion),
Encoding.UTF8, "application/json"),
};
}
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(respond(request));
}
}
}

View File

@@ -91,7 +91,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
// Assert: name / genotype / originBreeder / status / externalRef all intact
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
Assert.Equal("Breeding", GetStr(json, "status"));
Assert.Equal("Active", GetStr(json, "status"));
Assert.Equal("Charakter-Tier", GetStr(json, "name"));
Assert.Equal("female", GetStr(json, "gender"));
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
@@ -138,7 +138,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Minimal", GetStr(json, "name"));
Assert.Equal("Breeding", GetStr(json, "status"));
Assert.Equal("Active", GetStr(json, "status"));
// characterTraits defaults to [] on create when not provided
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
}

View File

@@ -16,7 +16,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
private readonly HttpClient _client;
public SearchVariationsTests(ApiFactory factory) => _client = factory.CreateClient();
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
private async Task Create(string name, string? originBreeder = null, string status = "Active")
{
var resp = await _client.PostAsJsonAsync("/gerbils",
new { name, gender = "female", status, originBreeder });
@@ -142,7 +142,7 @@ public class SearchVariationsTests : IClassFixture<ApiFactory>
public async Task Combined_status_and_name_filter()
{
await Create("KombiVerkauf", status: "ForSale");
await Create("KombiAktiv", status: "Breeding");
await Create("KombiAktiv", status: "Active");
var hits = await Names("status==ForSale,nameSearch=*kombi");
Assert.Contains("KombiVerkauf", hits);
Assert.DoesNotContain("KombiAktiv", hits);

View File

@@ -49,13 +49,6 @@ public class ApplicationContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// DB-4: ICU German collation on user-visible / searched text columns.
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
// support custom collation names and would fail EnsureCreated on the test host.
const string DeIcu = "de-x-icu";
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
modelBuilder.Entity<Gerbil>(e =>
{
// Enums persisted as their string names (readable, Gridify-friendly).
@@ -65,14 +58,6 @@ public class ApplicationContext : DbContext
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
if (isNpgsql)
{
e.Property(g => g.Name).UseCollation(DeIcu);
e.Property(g => g.NameSearch).UseCollation(DeIcu);
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
}
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
@@ -110,12 +95,6 @@ public class ApplicationContext : DbContext
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(l => l.Mother).WithMany()
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
// DB-5: ExternalRef = source litter id from extract.py.
// Unique (filtered, nulls allowed for manually-entered litters).
e.HasIndex(l => l.ExternalRef)
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
});
modelBuilder.Entity<HealthRecord>(e =>
@@ -197,15 +176,6 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql)
{
modelBuilder.Entity<ColorVariety>()
.Property(v => v.Name).UseCollation(DeIcu);
modelBuilder.Entity<Contact>()
.Property(c => c.Name).UseCollation(DeIcu);
}
SeedColorVarieties(modelBuilder);
}
@@ -282,97 +252,79 @@ public class ApplicationContext : DbContext
/// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder)
{
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
// ID = array_index + 1 (stable, never changes — live FKs safe).
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
(string Name, string Genotype, int SortOrder)[] catalog =
// GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
// Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
// SortOrder for new entries is appended; Kevin's frontend catalog handles
// the interleaved display order via its own sortOrder values.
(string Name, string Genotype)[] catalog =
{
// --- C-locus white / partial albino (IDs 1-3) ---
("REW", "AA chch DD EE GG pp spsp rere", 0),
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
// --- Zobel / colourpoint dark (ID 4) ---
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
// --- Schimmel family (IDs 5) ---
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
// --- Standard colours (IDs 6-15) ---
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
("Blau", "aa CC dd EE GG PP spsp rere", 10),
("Gold", "AA CC DD EE GG pp spsp rere", 11),
("Platin", "aa CC DD EE GG pp spsp rere", 12),
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
// --- Dilute (dd) standard (IDs 16-17) ---
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
("Topas", "AA CC DD EE GG pp spsp rere", 26),
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
// --- Dilute (dd) varieties (IDs 29-32) ---
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
// --- Hell variants (IDs 38-48) ---
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
// --- Dilute (dd) renamed variants (IDs 49-50) ---
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
// --- Marder / Siam / CP- series (IDs 51-66) ---
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
("Hermelin", "aa chch DD EE GG PP spsp rere"),
("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere"),
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
("Blau", "aa CC dd EE GG PP spsp rere"),
("Gold", "AA CC DD EE GG pp spsp rere"),
("Platin", "aa CC DD EE GG pp spsp rere"),
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
("dd Gold", "AA CC dd EE GG pp spsp rere"),
("dd Platin", "aa CC dd EE GG pp spsp rere"),
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere"),
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere"),
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
// GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
};
var rows = new ColorVariety[catalog.Length];
@@ -380,11 +332,11 @@ public class ApplicationContext : DbContext
{
rows[i] = new ColorVariety
{
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
Name = catalog[i].Name,
CanonicalGenotype = catalog[i].Genotype,
SortOrder = catalog[i].SortOrder,
SortOrder = i,
};
}
modelBuilder.Entity<ColorVariety>().HasData(rows);

View File

@@ -1,16 +0,0 @@
namespace GerbilManagerWebAPI.Cms
{
/// <summary>
/// WEB-2: path where POST /api/publish writes the rendered static site.
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
/// In production this volume is shared with the publicsite-nginx container.
/// </summary>
public sealed class PublicSiteOptions
{
public const string SectionName = "PublicSite";
public string? RootPath { get; set; }
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
}
}

View File

@@ -29,15 +29,13 @@ namespace GerbilManagerWebAPI.Dtos
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf,
bool IsResident,
string? ProfilePhotoUrl);
bool IsResident);
public record LitterDto(
Guid Id,
string Name,
DateOnly Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,
@@ -87,7 +85,6 @@ namespace GerbilManagerWebAPI.Dtos
string Name,
DateOnly Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
Guid? MotherId,
DateOnly? ExpectedGoHomeDate,

View File

@@ -1,4 +1,3 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using GerbilManagerWebAPI.Cms;
@@ -6,7 +5,6 @@ using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Endpoints
{
@@ -34,21 +32,6 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
});
// ---- WEB-2: publish — rendert Snapshot auf Disk, atomic swap live/ ----
api.MapPost("/publish", async (ApplicationContext db, IOptions<PublicSiteOptions> opts) =>
{
if (!opts.Value.IsConfigured)
return Results.Problem(
detail: "PublicSite__RootPath ist nicht konfiguriert. Setze die Umgebungsvariable.",
statusCode: 503,
title: "PublicSite nicht konfiguriert");
var snapshot = await new SiteSnapshotService(db).BuildAsync();
var files = SiteRenderer.Render(snapshot);
await PublishToDirectoryAsync(files, opts.Value.RootPath!);
return Results.Ok(new { filesPublished = files.Count });
});
// ---- WEB-3: lokale Vorschau — rendert live (nur veröffentlichte Seiten)
// und liefert die Datei mit passendem Content-Type aus. Relative
// Links/CSS der gerenderten Seite funktionieren dadurch im
@@ -186,36 +169,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app;
}
/// <summary>
/// WEB-2: Writes rendered files to <paramref name="rootPath"/>/_staging_new, then
/// atomically swaps to live/ (rename on the same filesystem = one syscall, never partial).
/// </summary>
internal static async Task PublishToDirectoryAsync(
IReadOnlyDictionary<string, string> files, string rootPath)
{
var stagingDir = Path.Combine(rootPath, "_staging_new");
var liveDir = Path.Combine(rootPath, "live");
var oldDir = Path.Combine(rootPath, "_old");
if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, recursive: true);
Directory.CreateDirectory(stagingDir);
foreach (var (relativePath, content) in files)
{
var normalPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.Combine(stagingDir, normalPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, content, Encoding.UTF8);
}
// Atomic swap: _staging_new → live
if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true);
if (Directory.Exists(liveDir)) Directory.Move(liveDir, oldDir);
Directory.Move(stagingDir, liveDir);
try { if (Directory.Exists(oldDir)) Directory.Delete(oldDir, recursive: true); }
catch { /* non-fatal — old dir gone on next publish */ }
}
/// <summary>WEB-3: Content-Type der Vorschau-Dateien (Renderer erzeugt HTML + CSS).</summary>
private static string PreviewContentType(string path) =>
path.EndsWith(".css", StringComparison.OrdinalIgnoreCase) ? "text/css; charset=utf-8"

View File

@@ -2,7 +2,6 @@ using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Contracts;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
@@ -105,12 +104,11 @@ namespace GerbilManagerWebAPI.Endpoints
db.SaleContracts.Add(entity);
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
foreach (var g in gerbils)
{
g.ReceiverContactId = contact.Id;
g.GoHomeDate = input.HandoverDate;
GerbilStatusService.Apply(g, today);
g.Status = GerbilStatus.GivenAway;
}
try

View File

@@ -1,9 +1,7 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Gridify;
using Gridify.EntityFramework;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -18,21 +16,8 @@ namespace GerbilManagerWebAPI.Endpoints
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
{
var q = query.ToQuery();
var paging = await db.Gerbils.AsNoTracking().GridifyAsync(q);
var ids = paging.Data.Select(g => g.Id).ToList();
var photoMap = ids.Count == 0 ? new Dictionary<Guid, string>() :
await db.GerbilPhotos.AsNoTracking()
.Where(p => ids.Contains(p.GerbilId))
.GroupBy(p => p.GerbilId)
.Select(g => new { GerbilId = g.Key, FileName = g.OrderBy(p => p.SortOrder).First().FileName })
.ToDictionaryAsync(x => x.GerbilId, x => x.FileName);
var items = paging.Data
.Select(g => ToDto(g, photoMap.TryGetValue(g.Id, out var fn) ? $"/photos/files/{fn}" : null))
.ToList();
return TypedResults.Ok(new PagedResult<GerbilDto>(items, paging.Count, q.Page, q.PageSize));
});
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
@@ -45,14 +30,7 @@ namespace GerbilManagerWebAPI.Endpoints
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return TypedResults.NotFound();
var photoFileName = await db.GerbilPhotos.AsNoTracking()
.Where(p => p.GerbilId == id)
.OrderBy(p => p.SortOrder)
.Select(p => p.FileName)
.FirstOrDefaultAsync();
var profilePhotoUrl = photoFileName != null ? $"/photos/files/{photoFileName}" : null;
return TypedResults.Ok(ToDto(g, profilePhotoUrl));
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
});
// POST /gerbils
@@ -108,8 +86,7 @@ namespace GerbilManagerWebAPI.Endpoints
{
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
// Status is applied as a user preference and then overridden by GerbilStatusService.
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
g.LitterId = i.LitterId ?? g.LitterId;
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
@@ -128,13 +105,12 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
}
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
}
}

View File

@@ -1,28 +0,0 @@
using GerbilManagerWebAPI.Import;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
public static class ImportDocxEndpoints
{
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/import/docx").WithTags("Import");
// POST /import/docx/dry-run — analyse without writing
group.MapPost("/dry-run",
async Task<Ok<ImportDocxReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
group.MapPost("/execute",
async Task<Ok<ImportDocxReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
return app;
}
}
}

View File

@@ -29,13 +29,6 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Ok(report);
});
group.MapPost("/ingest-resolved", async Task<Ok<string>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var result = await new IngestResolvedService(db, config, env).RunAsync();
return TypedResults.Ok(result);
});
return app;
}
}

View File

@@ -23,12 +23,10 @@ namespace GerbilManagerWebAPI.Endpoints
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
});
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (LitterInput input, ApplicationContext db) =>
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
{
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
Apply(l, input);
@@ -37,14 +35,12 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
});
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (Guid id, LitterInput input, ApplicationContext db) =>
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
{
var l = await db.Litters.FirstOrDefaultAsync(x => x.Id == id);
if (l is null) return TypedResults.NotFound();
var err = await ValidateParents(db, input.FatherId, input.MotherId);
if (err is not null) return TypedResults.BadRequest(err);
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
l.Name = input.Name;
l.Date = input.Date;
Apply(l, input);
@@ -80,25 +76,14 @@ namespace GerbilManagerWebAPI.Endpoints
private static void Apply(Litter l, LitterInput i)
{
l.TotalBorn = i.TotalBorn;
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
l.FatherId = i.FatherId;
l.MotherId = i.MotherId;
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
l.Notes = i.Notes;
}
private static string? ValidateMortality(int? deaths, int? totalBorn)
{
if (deaths is null) return null;
if (deaths < 0) return "DeathsWithin8Weeks darf nicht negativ sein.";
if (totalBorn is not null && deaths > totalBorn)
return "DeathsWithin8Weeks darf nicht größer als TotalBorn sein.";
return null;
}
private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
l.Id, l.Name, l.Date, l.TotalBorn, l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
}
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>

View File

@@ -1,38 +0,0 @@
using GerbilManagerWebAPI.Names;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
///
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
/// frontend maps exactly this code to its German disabled-state hint.
/// </summary>
public static class NamesEndpoints
{
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/names/suggest", async (
string? letter,
string? gender,
string? usages,
int count,
NameSuggestionService service,
CancellationToken ct) =>
{
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
return result.Status switch
{
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
NameSuggestionStatus.NotConfigured => Results.Json(
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
_ => Results.Json(
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
};
})
.WithTags("Names");
return app;
}
}
}

View File

@@ -48,11 +48,9 @@ namespace GerbilManagerWebAPI.Export
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
{
[GerbilStatus.Breeding] = "Zucht",
[GerbilStatus.Active] = "Aktiv",
[GerbilStatus.Deceased] = "Verstorben",
[GerbilStatus.GivenAway] = "Abgegeben",
[GerbilStatus.ForSale] = "Abzugeben",
[GerbilStatus.Pet] = "Liebhaber",
};
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()

View File

@@ -1,40 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
</ItemGroup>
<ItemGroup>
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
<InternalsVisibleTo Include="GerbilManager.Tests" />
</ItemGroup>
</Project>

View File

@@ -1,425 +0,0 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
/// <summary>
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
///
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
/// Match order per docx row:
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
///
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
/// -N ordinal suffix disambiguates same-name siblings in a litter).
///
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
///
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
/// Execute wraps all writes in a single transaction via CreateExecutionStrategy() so that
/// providers using EnableRetryOnFailure (e.g. NpgsqlRetryingExecutionStrategy) are
/// compatible. The strategy lambda resets all mutable state at the top so it is safe
/// to re-run on transient-failure retry.
/// Execute is gated by the endpoint; this service only acts when asked.
/// </summary>
public sealed class ImportDocxService
{
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
private readonly ApplicationContext _db;
private readonly string _sourceDir;
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
: this(db,
config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
{ }
public ImportDocxService(ApplicationContext db, string sourceDir)
{
_db = db;
_sourceDir = sourceDir;
}
public async Task<ImportDocxReport> RunAsync(bool execute)
{
var notes = new List<string>();
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
{
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
"extract_docx.py zuerst ausführen.");
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
}
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
// Date-only lookup with uniqueness guard avoids false links (only link when
// exactly one DB litter falls within ±5 days of the docx litter DOB).
var littersInDb = await _db.Litters
.Select(l => new { l.Id, l.Date })
.ToListAsync();
var littersByDayNumber = littersInDb
.GroupBy(l => l.Date.DayNumber)
.ToDictionary(g => g.Key, g => g.ToList());
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
var gerbilsInDb = await _db.Gerbils
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
g.ExternalRef })
.ToListAsync();
var gerbilByKey = gerbilsInDb
.Where(g => g.DateOfBirth is not null)
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
.ToDictionary(g => g.Key, g => g.ToList());
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
var docxExternalRefs = gerbilsInDb
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
.ToDictionary(g => g.ExternalRef!,
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
// Contact lookup: normalized name → existing Contact.Id
var contactsInDb = await _db.Contacts
.Select(c => new { c.Id, c.Name })
.ToListAsync();
var contactByNorm = contactsInDb
.GroupBy(c => NormalizeName(c.Name))
.ToDictionary(g => g.Key, g => g.First().Id);
// Snapshot of DB contacts before any writes.
// Used to reset contactByNorm on strategy retry (rolled-back contacts vanish from DB
// but would remain in the in-memory dict without this reset).
var contactByNormBase = new Dictionary<string, Guid>(contactByNorm);
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
var colorVarietyByName = (await _db.ColorVarieties
.Select(cv => new { cv.Id, cv.Name })
.ToListAsync())
.GroupBy(cv => NormalizeName(cv.Name))
.ToDictionary(g => g.Key, g => g.First().Id);
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
// Ordinal counter for collision-free ExternalRef within this batch.
var externalRefOrdinals = new Dictionary<string, int>();
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
var batchRefs = new HashSet<string>();
// Inner loop — shared by dry-run and execute paths.
// All local variables above are captured by reference (C# closure), so the strategy
// lambda can reset them before each retry and RunLoopAsync sees the fresh state.
async Task RunLoopAsync()
{
foreach (var da in docxAnimals)
{
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
// Collision-free ExternalRef: ordinal suffix for same-name siblings
var baseRef = DocxExternalRefBase(da);
externalRefOrdinals.TryGetValue(baseRef, out var ord);
ord++;
externalRefOrdinals[baseRef] = ord;
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
Guid? litterId = null;
if (!string.IsNullOrWhiteSpace(da.LitterDob))
{
var litterDob = ParseDate(da.LitterDob);
if (litterDob is not null)
{
var candidates = new List<Guid>();
for (int delta = -5; delta <= 5; delta++)
{
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
candidates.AddRange(cl.Select(l => l.Id));
}
if (candidates.Count == 1)
litterId = candidates[0];
// If 0 or >1 candidates: no link (avoid false links)
}
}
// Animal DOB = litter birth date (docx has no per-animal DOB)
var animalDob = litterId is not null
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
: ParseDate(da.LitterDob);
if (animalDob is null) { skipped++; continue; }
var goHomeDate = ParseDate(da.AbgabeDate);
var deathDate = ParseDate(da.DeathDate);
// Resolve receiver contact (lookup-or-create; shared by all paths)
Guid? receiverId = null;
if (!string.IsNullOrWhiteSpace(da.Owner))
{
var normOwner = NormalizeName(da.Owner);
if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
{
receiverId = existingContactId;
ownerLinked++;
}
else
{
ownerCreated++;
if (execute)
{
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
_db.Contacts.Add(newContact);
await _db.SaveChangesAsync(); // flush within the outer tx
receiverId = newContact.Id;
contactByNorm[normOwner] = receiverId.Value;
}
}
}
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
{
bool willLink = litterId is not null && prevSnap.LitterId is null;
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
if (willLink) litterLinked++;
if (willHome) goHomeFilled++;
if (willDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
if (row is null) continue;
if (willLink) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
if (willHome) row.GoHomeDate = goHomeDate;
if (willDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
continue;
}
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
var nameKey = NameDobKey(da.Name, animalDob.Value);
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
{
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
?? gerbilCands.First();
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
if (willLink) litterLinked++;
if (willHome) goHomeFilled++;
if (willDeath) deathFilled++;
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
if (row is null) continue;
if (willLink) row.LitterId = litterId;
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
if (willHome) row.GoHomeDate = goHomeDate;
if (willDeath)
{
row.DateOfDeath = deathDate;
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
row.CauseOfDeath = da.DeathCause.Trim();
}
}
continue;
}
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
animalsCreated++;
if (litterId is not null) litterLinked++;
if (goHomeDate is not null) goHomeFilled++;
if (deathDate is not null) deathFilled++;
if (execute)
{
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
_db.Gerbils.Add(new Gerbil
{
Id = Guid.NewGuid(),
Name = da.Name.Trim(),
DateOfBirth = animalDob,
Gender = ParseGender(da.Gender),
Status = GerbilStatusService.Derive(GerbilStatus.GivenAway, animalDob, deathDate, isAbgegeben: receiverId is not null, DateOnly.FromDateTime(DateTime.UtcNow)),
LitterId = litterId,
ReceiverContactId = receiverId,
GoHomeDate = goHomeDate,
DateOfDeath = deathDate,
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
ColorVarietyId = cvId == default ? null : cvId,
OriginBreeder = "Zucht der Kleinen Chaoten",
IsResident = false,
ImportSource = "docx",
ExternalRef = externalRef,
});
}
}
}
if (!execute)
{
// Dry-run: just count, no writes, no transaction needed.
await RunLoopAsync();
}
else
{
// Execute: wrap the entire transaction in the execution strategy so that providers
// with EnableRetryOnFailure (NpgsqlRetryingExecutionStrategy) are compatible.
// The lambda resets all mutable state at the top so retries start clean.
var strategy = _db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
// Reset mutable state — idempotent on strategy retry
_db.ChangeTracker.Clear();
externalRefOrdinals.Clear();
batchRefs.Clear();
animalsCreated = 0; litterLinked = 0; goHomeFilled = 0; deathFilled = 0;
ownerLinked = 0; ownerCreated = 0; skipped = 0;
// Rebuild from DB snapshot: contacts added in a failed attempt were rolled back
contactByNorm = new Dictionary<string, Guid>(contactByNormBase);
await using var tx = await _db.Database.BeginTransactionAsync();
await RunLoopAsync();
if ((animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
await _db.SaveChangesAsync();
await tx.CommitAsync();
});
}
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
}
private T? Load<T>(string file)
{
var path = Path.Combine(_sourceDir, file);
if (!File.Exists(path)) return default;
using var fs = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(fs, Json);
}
private static DateOnly? ParseDate(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
var m = System.Text.RegularExpressions.Regex.Match(s,
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
if (!m.Success) return null;
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
int y = int.Parse(m.Groups[3].Value);
if (y < 100) y += 2000;
try { return new DateOnly(y, mo, d); } catch { return null; }
}
private static string NameDobKey(string name, DateOnly dob)
{
var n = System.Text.RegularExpressions.Regex.Replace(
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
return $"{n}|{dob:yyyy-MM-dd}";
}
private static string NormalizeName(string name)
{
var n = (name ?? "").ToLowerInvariant();
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
return n;
}
/// <summary>
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
/// </summary>
internal static string DocxExternalRefBase(DocxAnimal da)
{
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
var name = System.Text.RegularExpressions.Regex.Replace(
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
return $"docx-{ws}-{name}-{da.LitterDob}";
}
private static Gender ParseGender(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
var lower = s.ToLowerInvariant();
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
return Gender.unknown;
}
}
// ---- Source shapes (from extract_docx.py output) ----
public sealed class DocxLitter
{
public string LitterId { get; set; } = "";
public string Dob { get; set; } = "";
public string MotherName { get; set; } = "";
public string FatherName { get; set; } = "";
public string WsCode { get; set; } = "";
public string Note { get; set; } = "";
}
public sealed class DocxAnimal
{
public string WsCode { get; set; } = "";
public string LitterDob { get; set; } = "";
public string Name { get; set; } = "";
public string Farbschlag { get; set; } = "";
public string Gender { get; set; } = "";
public string Owner { get; set; } = "";
public string AbgabeDate { get; set; } = "";
public string AbgabeWeight { get; set; } = "";
public string DeathDate { get; set; } = "";
public string DeathCause { get; set; } = "";
public string PartnerName { get; set; } = "";
public string PartnerDob { get; set; } = "";
}
// ---- Report ----
public sealed record ImportDocxReport(
bool Executed,
int Created,
int LitterLinked,
int OwnerLinked,
int GoHomeFilled,
int DeathFilled,
int ContactsCreated,
int Skipped,
IReadOnlyList<string> Notes);
}

View File

@@ -1,7 +1,6 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
@@ -72,21 +71,17 @@ namespace GerbilManagerWebAPI.Import
else if (conf == "niedrig") dateOnly++;
}
// existing rows (idempotency).
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
var existingGerbilExtRefs = await _db.Gerbils
.Where(g => g.ExternalRef != null)
.Select(g => g.ExternalRef!).ToListAsync();
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
var existingLitterData = await _db.Litters
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
var existingLitterKeySet = existingLitterData
var existingLitterKeys = await _db.Litters
.Select(l => new { l.Name, l.Date }).ToListAsync();
var existingLitterKeySet = existingLitterKeys
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
var existingLitterExtRefSet = existingLitterData
.Where(x => x.ExternalRef != null)
.Select(x => x.ExternalRef!).ToHashSet();
// colour-variety name -> id (case-insensitive)
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
@@ -99,10 +94,10 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
// so skip them early — they can never be created or linked to animals.
// so their existingLitterKeySet key was always "" → they were always counted as
// "created" even though the execute block skipped them (date is DateOnly d = false).
// Fix: skip undated litters early — they can never be created or linked to animals.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters)
@@ -112,10 +107,7 @@ namespace GerbilManagerWebAPI.Import
var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}";
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
// for litters imported before ExternalRef existed (those have ExternalRef = null).
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
{ littersExisting++; continue; }
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
var id = Guid.NewGuid();
litterIdMap[sl.Id] = id;
@@ -130,7 +122,6 @@ namespace GerbilManagerWebAPI.Import
TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
});
}
if (samples.Count < 8)
@@ -290,15 +281,10 @@ namespace GerbilManagerWebAPI.Import
litterParents[lid] = (f, m);
}
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
// which would cause EF's topo-sort to throw "circular dependency detected".
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
// before the animals — the offspring AND the parent gerbils are created in the loop
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
if (execute)
{
// reuse an existing litter with the same parents+date instead of duplicating.
@@ -322,17 +308,13 @@ namespace GerbilManagerWebAPI.Import
derivedLitters--;
continue;
}
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
// in this same batch. Setting them now causes EF circular dependency
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
_db.Litters.Add(new Litter
{
Id = sl.Id,
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
Date = sl.Date ?? default,
FatherId = null, // deferred — applied after gerbils SaveChanges
MotherId = null, // deferred — applied after gerbils SaveChanges
FatherId = sl.Father,
MotherId = sl.Mother,
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
});
}
@@ -391,12 +373,12 @@ namespace GerbilManagerWebAPI.Import
if (execute)
{
var importedGerbil = new Gerbil
_db.Gerbils.Add(new Gerbil
{
Id = p.Gid,
Name = p.A.Name,
Gender = p.Gender,
Status = GerbilStatus.Breeding,
Status = GerbilStatus.Active,
DateOfBirth = ParseDate(p.A.Dob),
DateOfDeath = ParseDate(p.A.Death),
LitterId = litterId,
@@ -420,9 +402,7 @@ namespace GerbilManagerWebAPI.Import
p.A.SourceFiles,
FarbschlagRaw = p.A.Farbschlag,
}),
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
_db.Gerbils.Add(importedGerbil);
});
}
// photos
@@ -449,20 +429,6 @@ namespace GerbilManagerWebAPI.Import
}
if (execute) await _db.SaveChangesAsync();
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
if (execute && synthLitterPendingParents.Count > 0)
{
foreach (var (litId, (f, m)) in synthLitterPendingParents)
{
var row = await _db.Litters.FindAsync(litId);
if (row is null) continue;
if (f is not null) row.FatherId = f;
if (m is not null) row.MotherId = m;
}
await _db.SaveChangesAsync();
}
// ---- back-link Wurfchronik litter parents by name (best effort) ----
if (execute)
{

View File

@@ -1,192 +0,0 @@
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
public sealed class IngestResolvedService
{
private readonly ApplicationContext _db;
private readonly string _resolvedJsonPath;
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
{
_db = db;
var sourceDir = config["Import:SourcePath"]
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output"));
_resolvedJsonPath = Path.Combine(sourceDir, "resolved_import.json");
}
public async Task<string> RunAsync()
{
if (!File.Exists(_resolvedJsonPath))
{
return $"Error: Resolved import file not found at {_resolvedJsonPath}. Run resolve_import.py first.";
}
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
var jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
if (data == null)
{
return "Error: Failed to deserialize resolved_import.json.";
}
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
// Break circular references first
foreach (var l in _db.Litters)
{
l.FatherId = null;
l.MotherId = null;
}
foreach (var g in _db.Gerbils)
{
g.LitterId = null;
}
await _db.SaveChangesAsync();
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
_db.WeightRecords.RemoveRange(_db.WeightRecords);
_db.HealthRecords.RemoveRange(_db.HealthRecords);
_db.Gerbils.RemoveRange(_db.Gerbils);
_db.Litters.RemoveRange(_db.Litters);
await _db.SaveChangesAsync();
}
else
{
// Break circular references first using ExecuteUpdateAsync
await _db.Litters.ExecuteUpdateAsync(s => s
.SetProperty(l => l.FatherId, (Guid?)null)
.SetProperty(l => l.MotherId, (Guid?)null));
await _db.Gerbils.ExecuteUpdateAsync(s => s
.SetProperty(g => g.LitterId, (Guid?)null));
await _db.GerbilPhotos.ExecuteDeleteAsync();
await _db.WeightRecords.ExecuteDeleteAsync();
await _db.HealthRecords.ExecuteDeleteAsync();
await _db.Gerbils.ExecuteDeleteAsync();
await _db.Litters.ExecuteDeleteAsync();
}
// 2. Import Contacts (Only add new ones to preserve manually entered contacts)
var existingContactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
var addedContactIds = new HashSet<Guid>();
int contactsAdded = 0;
foreach (var c in data.Contacts)
{
if (!existingContactIds.Contains(c.Id) && addedContactIds.Add(c.Id))
{
_db.Contacts.Add(c);
contactsAdded++;
}
}
await _db.SaveChangesAsync();
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
var littersToInsert = new List<Litter>();
foreach (var l in data.Litters)
{
littersToInsert.Add(new Litter
{
Id = l.Id,
Name = l.Name,
Date = l.Date,
TotalBorn = l.TotalBorn,
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
FatherId = null, // Set in Pass 2
MotherId = null, // Set in Pass 2
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
Notes = l.Notes,
PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter
});
}
_db.Litters.AddRange(littersToInsert);
await _db.SaveChangesAsync();
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
var gerbilsToInsert = new List<Gerbil>();
foreach (var g in data.Gerbils)
{
gerbilsToInsert.Add(new Gerbil
{
Id = g.Id,
Name = g.Name,
Gender = g.Gender,
Status = g.Status,
LitterId = null, // Set in Pass 2
OriginContactId = g.OriginContactId,
ReceiverContactId = g.ReceiverContactId,
EnclosureId = g.EnclosureId,
ColorVarietyId = g.ColorVarietyId,
DateOfBirth = g.DateOfBirth,
DateOfDeath = g.DateOfDeath,
CauseOfDeath = g.CauseOfDeath,
GoHomeDate = g.GoHomeDate,
Genotype = g.Genotype,
Notes = g.Notes,
ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData,
OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits,
CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf,
IsResident = g.IsResident
});
}
_db.Gerbils.AddRange(gerbilsToInsert);
await _db.SaveChangesAsync();
// 5. Pass 2: Set the actual foreign key relationships
// Update Litters with their parent animal IDs
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
foreach (var l in data.Litters)
{
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
{
dbLitter.FatherId = l.FatherId;
dbLitter.MotherId = l.MotherId;
}
}
// Update Gerbils with their actual LitterId
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
foreach (var g in data.Gerbils)
{
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
{
dbGerbil.LitterId = g.LitterId;
}
}
await _db.SaveChangesAsync();
// 6. Insert all Photos
foreach (var p in data.GerbilPhotos)
{
_db.GerbilPhotos.Add(p);
}
await _db.SaveChangesAsync();
return $"Ingestion successful! Imported {contactsAdded} contacts, {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
}
}
public class ResolvedImportData
{
public List<Contact> Contacts { get; set; } = new();
public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { get; set; } = new();
public List<GerbilPhoto> GerbilPhotos { get; set; } = new();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,133 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGermanCollation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalRef",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
collation: "de-x-icu",
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.CreateIndex(
name: "IX_Litters_ExternalRef",
table: "Litters",
column: "ExternalRef",
unique: true,
filter: "\"ExternalRef\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Litters_ExternalRef",
table: "Litters");
migrationBuilder.DropColumn(
name: "ExternalRef",
table: "Litters");
migrationBuilder.AlterColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true,
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Gerbils",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "Contacts",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "ColorVarieties",
type: "text",
nullable: false,
oldClrType: typeof(string),
oldType: "text",
oldCollation: "de-x-icu");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterExternalRef : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterLetter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LitterLetter",
table: "Litters",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LitterLetter",
table: "Litters");
}
}
}

View File

@@ -1,598 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class ReseedColorVarietiesAR5 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
column: "Name",
value: "Dilute Gold");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
column: "Name",
value: "Dilute Platin");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
column: "Name",
value: "Dilute Agouti");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
column: "Name",
value: "Dilute Silberagouti");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
column: "Name",
value: "Dilute Kohlfuchs");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
column: "Name",
value: "Dilute Anthrazit");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
column: "SortOrder",
value: 36);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "SortOrder",
value: 37);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "SortOrder",
value: 38);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "SortOrder",
value: 39);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "SortOrder",
value: 40);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
column: "SortOrder",
value: 41);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
column: "SortOrder",
value: 42);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "SortOrder",
value: 43);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
column: "SortOrder",
value: 44);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
column: "SortOrder",
value: 45);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "SortOrder",
value: 46);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
column: "SortOrder",
value: 47);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "SortOrder",
value: 48);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
column: "SortOrder",
value: 49);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
column: "SortOrder",
value: 50);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "SortOrder",
value: 51);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Dilute Topas", 52 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Dilute Blaufuchs", 53 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "SortOrder",
value: 54);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
column: "SortOrder",
value: 55);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "SortOrder",
value: 56);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
column: "SortOrder",
value: 57);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
column: "SortOrder",
value: 59);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
column: "SortOrder",
value: 61);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
column: "SortOrder",
value: 63);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
column: "SortOrder",
value: 65);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
column: "SortOrder",
value: 66);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
column: "SortOrder",
value: 67);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
column: "SortOrder",
value: 68);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
column: "SortOrder",
value: 58);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
column: "SortOrder",
value: 60);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
column: "SortOrder",
value: 62);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
column: "SortOrder",
value: 69);
migrationBuilder.InsertData(
table: "ColorVarieties",
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
values: new object[,]
{
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
migrationBuilder.DeleteData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
column: "Name",
value: "dd Gold");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
column: "Name",
value: "dd Platin");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
column: "Name",
value: "Agouti dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
column: "Name",
value: "Silberagouti dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
column: "Name",
value: "Kohlfuchs dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
column: "Name",
value: "Anthrazit dd");
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
column: "SortOrder",
value: 32);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
column: "SortOrder",
value: 33);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
column: "SortOrder",
value: 34);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
column: "SortOrder",
value: 35);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
column: "SortOrder",
value: 36);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
column: "SortOrder",
value: 37);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
column: "SortOrder",
value: 38);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
column: "SortOrder",
value: 39);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
column: "SortOrder",
value: 40);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
column: "SortOrder",
value: 41);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
column: "SortOrder",
value: 42);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
column: "SortOrder",
value: 43);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
column: "SortOrder",
value: 44);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
column: "SortOrder",
value: 45);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
column: "SortOrder",
value: 46);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
column: "SortOrder",
value: 47);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Topas dd", 48 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
columns: new[] { "Name", "SortOrder" },
values: new object[] { "Blaufuchs dd", 49 });
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
column: "SortOrder",
value: 50);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
column: "SortOrder",
value: 51);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
column: "SortOrder",
value: 52);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
column: "SortOrder",
value: 53);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
column: "SortOrder",
value: 54);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
column: "SortOrder",
value: 55);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
column: "SortOrder",
value: 56);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
column: "SortOrder",
value: 57);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
column: "SortOrder",
value: 58);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
column: "SortOrder",
value: 59);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
column: "SortOrder",
value: 60);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
column: "SortOrder",
value: 61);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
column: "SortOrder",
value: 62);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
column: "SortOrder",
value: 63);
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
column: "SortOrder",
value: 65);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class RenameREW : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "REW");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "ColorVarieties",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
column: "Name",
value: "Pink Eyed White (PEW)");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class StatusModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1) Rename string value 'Active' → 'Breeding'.
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Breeding' WHERE ""Status"" = 'Active';");
// 2) DateOfDeath set → Deceased (always takes precedence).
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Deceased' WHERE ""DateOfDeath"" IS NOT NULL;");
// 3) ReceiverContactId OR SaleContractAnimal → GivenAway (if not already Deceased).
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'GivenAway'
WHERE ""Status"" != 'Deceased'
AND (""ReceiverContactId"" IS NOT NULL
OR EXISTS (SELECT 1 FROM ""SaleContractAnimal"" WHERE ""GerbilId"" = ""Gerbils"".""Id""));");
// 4) Age > 7 years (presumed dead) → Deceased, but NOT overriding GivenAway.
migrationBuilder.Sql(@"
UPDATE ""Gerbils"" SET ""Status"" = 'Deceased'
WHERE ""Status"" NOT IN ('Deceased', 'GivenAway')
AND ""DateOfBirth"" IS NOT NULL
AND ""DateOfBirth"" < (CURRENT_DATE - INTERVAL '7 years');");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE ""Gerbils"" SET ""Status"" = 'Active' WHERE ""Status"" = 'Breeding';");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddLitterMortality : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DeathsWithin8Weeks",
table: "Litters",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeathsWithin8Weeks",
table: "Litters");
}
}
}

View File

@@ -201,8 +201,7 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.UseCollation("de-x-icu");
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
@@ -216,7 +215,7 @@ namespace GerbilManagerWebAPI.Migrations
{
Id = new Guid("00000000-0000-0000-0000-000000000001"),
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
Name = "REW",
Name = "Pink Eyed White (PEW)",
SortOrder = 0
},
new
@@ -321,14 +320,14 @@ namespace GerbilManagerWebAPI.Migrations
{
Id = new Guid("00000000-0000-0000-0000-000000000016"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Dilute Gold",
Name = "dd Gold",
SortOrder = 15
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000017"),
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
Name = "Dilute Platin",
Name = "dd Platin",
SortOrder = 16
},
new
@@ -412,28 +411,28 @@ namespace GerbilManagerWebAPI.Migrations
{
Id = new Guid("00000000-0000-0000-0000-000000000029"),
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
Name = "Dilute Agouti",
Name = "Agouti dd",
SortOrder = 28
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000030"),
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
Name = "Dilute Silberagouti",
Name = "Silberagouti dd",
SortOrder = 29
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000031"),
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
Name = "Dilute Kohlfuchs",
Name = "Kohlfuchs dd",
SortOrder = 30
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000032"),
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
Name = "Dilute Anthrazit",
Name = "Anthrazit dd",
SortOrder = 31
},
new
@@ -441,224 +440,224 @@ namespace GerbilManagerWebAPI.Migrations
Id = new Guid("00000000-0000-0000-0000-000000000033"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Silberschimmel",
SortOrder = 36
SortOrder = 32
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000034"),
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
Name = "Polarfuchsschimmel",
SortOrder = 37
SortOrder = 33
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000035"),
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
Name = "Algierfuchsschimmel",
SortOrder = 38
SortOrder = 34
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000036"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel",
SortOrder = 39
SortOrder = 35
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000037"),
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
Name = "Blaufuchsschimmel",
SortOrder = 40
SortOrder = 36
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000038"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs, hell",
SortOrder = 41
SortOrder = 37
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000039"),
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
Name = "Goldfuchs, hell",
SortOrder = 42
SortOrder = 38
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000040"),
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
Name = "Goldfuchsschimmel",
SortOrder = 43
SortOrder = 39
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000041"),
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
Name = "Gold-Hell",
SortOrder = 44
SortOrder = 40
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000042"),
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
Name = "Blaufuchs, hell",
SortOrder = 45
SortOrder = 41
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000043"),
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
Name = "Rotfuchsschimmel",
SortOrder = 46
SortOrder = 42
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000044"),
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
Name = "Polarfuchs, hell",
SortOrder = 47
SortOrder = 43
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000045"),
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
Name = "Kohlfuchsschimmel, hell",
SortOrder = 48
SortOrder = 44
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000046"),
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
Name = "Rotfuchs, hell",
SortOrder = 49
SortOrder = 45
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000047"),
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
Name = "Kohlfuchs-Hell",
SortOrder = 50
SortOrder = 46
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000048"),
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
Name = "Algierfuchs, hell",
SortOrder = 51
SortOrder = 47
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000049"),
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
Name = "Dilute Topas",
SortOrder = 52
Name = "Topas dd",
SortOrder = 48
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000050"),
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
Name = "Dilute Blaufuchs",
SortOrder = 53
Name = "Blaufuchs dd",
SortOrder = 49
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000051"),
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
Name = "Marder",
SortOrder = 54
SortOrder = 50
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000052"),
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
Name = "Siam",
SortOrder = 55
SortOrder = 51
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000053"),
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
Name = "Zobel-Hell",
SortOrder = 56
SortOrder = 52
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000054"),
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
Name = "CP-Agouti",
SortOrder = 57
SortOrder = 53
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000055"),
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
Name = "CP-Silberagouti",
SortOrder = 59
SortOrder = 54
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000056"),
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
Name = "CP-Algierfuchs",
SortOrder = 61
SortOrder = 55
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000057"),
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
Name = "CP-Polarfuchs",
SortOrder = 63
SortOrder = 56
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000058"),
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
Name = "CP-Fuchs",
SortOrder = 65
SortOrder = 57
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000059"),
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
Name = "CP-Fuchs-Hell",
SortOrder = 66
SortOrder = 58
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000060"),
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
Name = "CP-Blaufuchs",
SortOrder = 67
SortOrder = 59
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000061"),
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel",
SortOrder = 68
SortOrder = 60
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
Name = "CP-Agouti-Hell",
SortOrder = 58
SortOrder = 61
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
Name = "CP-Silberagouti-Hell",
SortOrder = 60
SortOrder = 62
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
Name = "CP-Algierfuchs-Hell",
SortOrder = 62
SortOrder = 63
},
new
{
@@ -672,35 +671,7 @@ namespace GerbilManagerWebAPI.Migrations
Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel-Hell",
SortOrder = 69
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000067"),
CanonicalGenotype = "AA CC dd ee GG PP spsp rere",
Name = "Dilute Algierfuchs",
SortOrder = 32
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000068"),
CanonicalGenotype = "AA CC dd ee GG pp spsp rere",
Name = "Dilute Goldfuchs",
SortOrder = 33
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000069"),
CanonicalGenotype = "aa CC dd ee GG pp spsp rere",
Name = "Dilute Rotfuchs",
SortOrder = 34
},
new
{
Id = new Guid("00000000-0000-0000-0000-000000000070"),
CanonicalGenotype = "AA CC dd ee gg PP spsp rere",
Name = "Dilute Polarfuchs",
SortOrder = 35
SortOrder = 65
});
});
@@ -718,8 +689,7 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.UseCollation("de-x-icu");
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
@@ -807,19 +777,16 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.UseCollation("de-x-icu");
.HasColumnType("text");
b.Property<string>("NameSearch")
.HasColumnType("text")
.UseCollation("de-x-icu");
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("OriginBreeder")
.HasColumnType("text")
.UseCollation("de-x-icu");
.HasColumnType("text");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
@@ -924,21 +891,12 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<int?>("DeathsWithin8Weeks")
.HasColumnType("integer");
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
b.Property<string>("ExternalRef")
.HasColumnType("text");
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<string>("LitterLetter")
.HasColumnType("text");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");
@@ -957,10 +915,6 @@ namespace GerbilManagerWebAPI.Migrations
b.HasKey("Id");
b.HasIndex("ExternalRef")
.IsUnique()
.HasFilter("\"ExternalRef\" IS NOT NULL");
b.HasIndex("FatherId");
b.HasIndex("MotherId");

View File

@@ -13,7 +13,7 @@ namespace GerbilManagerWebAPI.Models
public Guid Id { get; set; }
public required string Name { get; set; }
public Gender Gender { get; set; }
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
public GerbilStatus Status { get; set; } = GerbilStatus.Active;
// Birth litter (the litter this gerbil was born in).
public Guid? LitterId { get; set; }

View File

@@ -1,17 +1,12 @@
namespace GerbilManagerWebAPI.Models
{
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.
/// Deceased and GivenAway are DERIVED (set by GerbilStatusService, not free-form input).
/// Breeding/Pet/ForSale are the user-selectable live states.</summary>
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
public enum GerbilStatus
{
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
Breeding = 0,
Active = 0,
Deceased = 1,
GivenAway = 2,
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
ForSale = 3,
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
Pet = 4,
ForSale = 3
}
}

View File

@@ -13,10 +13,6 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Total born count (was "Strength").</summary>
public int? TotalBorn { get; set; }
/// <summary>Frühsterblichkeit: Anzahl der in den ersten 8 Wochen verstorbenen Tiere.
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
public int? DeathsWithin8Weeks { get; set; }
public Guid? FatherId { get; set; }
public Gerbil? Father { get; set; }
public Guid? MotherId { get; set; }
@@ -29,14 +25,5 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
public string? PairingCode { get; set; }
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
/// Name+Date is the fallback for litters created before this column existed.</summary>
public string? ExternalRef { get; set; }
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; }
}
}

View File

@@ -1,5 +0,0 @@
namespace GerbilManagerWebAPI.Names
{
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
}

View File

@@ -1,162 +0,0 @@
using System.Text;
using System.Text.Json;
using GerbilManagerWebAPI.Ai;
using GerbilManagerWebAPI.SaleAd;
using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.Names
{
/// <summary>
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
/// Returns NotConfigured when the AI section is missing — callers map to 503.
/// </summary>
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
private static readonly JsonSerializerOptions JsonOpts = new()
{
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)
{
var aiResult = await _client.CompleteAsync(
BuildSystemPrompt(),
BuildUserPrompt(letter, gender, usages, count),
ct);
if (aiResult.Status == AiCallStatus.NotConfigured)
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
var suggestions = ParseSuggestions(aiResult.Text);
return suggestions is null
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
}
internal static string BuildSystemPrompt() =>
"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). " +
"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)
{
var sb = new StringBuilder();
sb.Append($"Schlage {count} Rennmaus-Namen vor");
if (!string.IsNullOrWhiteSpace(letter))
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
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))
{
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();
}
/// <summary>
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
/// its JSON output, then deserialises the array.
/// </summary>
internal static List<NameSuggestion>? ParseSuggestions(string raw)
{
var text = raw.Trim();
// Strip ```json ... ``` or ``` ... ``` fences.
if (text.StartsWith("```", StringComparison.Ordinal))
{
var firstNewline = text.IndexOf('\n');
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
if (text.EndsWith("```", StringComparison.Ordinal))
text = text[..^3].TrimEnd();
}
// Find the JSON array bounds defensively.
var start = text.IndexOf('[');
var end = text.LastIndexOf(']');
if (start < 0 || end <= start) return null;
text = text[start..(end + 1)];
try
{
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
}
catch (JsonException)
{
return null;
}
}
}
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
public sealed record NameSuggestionResult(
NameSuggestionStatus Status,
List<NameSuggestion>? Suggestions,
string? Error);
}

View File

@@ -1,7 +1,5 @@
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Endpoints;
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Services;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
@@ -47,12 +45,6 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// WEB-2: public site publish path (env var PublicSite__RootPath; empty = disabled)
builder.Services.AddOptions<GerbilManagerWebAPI.Cms.PublicSiteOptions>()
.BindConfiguration(GerbilManagerWebAPI.Cms.PublicSiteOptions.SectionName);
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
@@ -86,23 +78,7 @@ app.MapScalarApiReference();
if (!app.Environment.IsEnvironment("Testing"))
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
db.Database.Migrate();
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
// since the last write. No-op if all statuses are already current.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var candidates = await db.Gerbils
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
&& g.DateOfDeath == null && g.ReceiverContactId == null
&& g.DateOfBirth != null
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
.ToListAsync();
if (candidates.Count > 0)
{
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
await db.SaveChangesAsync();
}
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
}
app.UseCors(LanCorsPolicy);
@@ -119,13 +95,11 @@ app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.MapImportDocxEndpoints();
app.MapContractEndpoints();
app.MapSettingsEndpoints();
app.MapExportEndpoints();
app.MapCmsEndpoints();
app.MapRequestEndpoints();
app.MapNamesEndpoints();
app.Run();

View File

@@ -1,53 +0,0 @@
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Services
{
/// <summary>
/// Central status-derivation logic. Every write path (create/update gerbil, SaleContract
/// Abgabe, import, startup sweep) calls Apply() after setting the other fields so the
/// derived statuses (Deceased, GivenAway) are always consistent.
///
/// Precedence (highest wins):
/// 1) DateOfDeath set → Deceased (explicit, always)
/// 2) Abgabe (ReceiverContactId set) → GivenAway
/// 3) Age > MaxAgeYears without a death date or Abgabe → Deceased (presumed)
/// 4) User-supplied {Breeding, Pet, ForSale}; defaults Breeding if invalid
///
/// Age-based death is time-dependent. The stored column is kept up-to-date by:
/// a) Apply() on every write (catches the animal at write time)
/// b) A startup sweep in Program.cs (catches animals that silently crossed the threshold)
/// Gridify filters on the stored value, so status==Breeding never surfaces >7y animals.
/// </summary>
public static class GerbilStatusService
{
public const int MaxAgeYears = 7;
/// <summary>Derives and sets g.Status using the gerbil's current field values.
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)
/// have been applied. today = DateOnly.FromDateTime(DateTime.UtcNow).</summary>
public static void Apply(Gerbil g, DateOnly today)
{
g.Status = Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
isAbgegeben: g.ReceiverContactId is not null, today);
}
/// <summary>Pure derivation — useful for tests and the migration backfill.</summary>
public static GerbilStatus Derive(
GerbilStatus requested,
DateOnly? dateOfBirth,
DateOnly? dateOfDeath,
bool isAbgegeben,
DateOnly today)
{
if (dateOfDeath is not null) return GerbilStatus.Deceased;
if (isAbgegeben) return GerbilStatus.GivenAway;
if (dateOfBirth is not null && IsOlderThan(dateOfBirth.Value, MaxAgeYears, today))
return GerbilStatus.Deceased;
return requested is GerbilStatus.Breeding or GerbilStatus.Pet or GerbilStatus.ForSale
? requested : GerbilStatus.Breeding;
}
private static bool IsOlderThan(DateOnly dob, int years, DateOnly today) =>
today >= dob.AddYears(years);
}
}

168
HUMANQUESTION.md Normal file
View File

@@ -0,0 +1,168 @@
# HUMANQUESTION.md — offene Fragen & benötigte Daten
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
> alles wartet geduldig, bis ihr antwortet.
_Stand: 2026-06-06._
Der Manager ist fertig und läuft. Die alten Daten sind importiert
(**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
---
## A. Für Julian — Konten, Schlüssel, Technik
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|---|---|---|---|
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~**AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
## B. Für Julian — kleine Aktionen (jederzeit)
| # | Aktion | Warum |
|---|---|---|
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
---
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
| # | Frage | Blockiert |
|---|---|---|
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
---
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|---|---|---|
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-****DD** | ✅ **D-** (DD war Tippfehler) — Julian |
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-****PP** | ✅ **PP** — Julian |
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-****DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-****Cc[h]** · E-Locus: **E-****EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee****ee** · P-Locus: **P-****Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee****ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`****ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
>
> Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
| Tier | Konkreter Konflikt — was stimmt? | Status |
|---|---|---|
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-****DD** (WP gleich in beiden) | ✅ **DD** — Julian |
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]****ee[f]****Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
### D5 · **Sterbedatum** widersprüchlich
| Tier | Problem |
|---|---|
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
| Tier | Konkreter Konflikt — was stimmt? |
|---|---|
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP****Pp** |
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp****spsp** (Schecke ja/nein) |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016****2018** |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
---
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
sagen, was ergänzt oder gestrichen werden soll:**
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
---
## F. Was hängt woran (Kurzübersicht)
| Funktion | Status | Wartet auf |
|---|---|---|
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
sie hier und meldet, was dadurch live geht.*

View File

@@ -1,105 +0,0 @@
# HUMANQUESTION.md — offene Fragen & benötigte Daten
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
> das, was **noch offen** ist. Nichts geht verloren.
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
Feinschliff** — siehe unten._
---
## A. Zugänge & Technik (für Julian)
| # | Was gebraucht wird | Schaltet frei |
|---|---|---|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. |
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager` → **Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) |
## B. Kleine Aktion (jederzeit)
| # | Aktion | Warum |
|---|---|---|
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — |
## C. Genetik-Feinschliff (optional, blockiert nichts)
| # | Frage | Betrifft |
|---|---|---|
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|---|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx |
| ~~Osamu v.d. K.C. (*10.12.2015)~~ ✅ | **18.12.2020** (erledigt) | — |
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
| Tier | Konflikt — was stimmt? | Quelldatei(en) |
|---|---|---|
| ~~Milon v.d. K.C. (*27.11.2014)~~ ✅ | A-Locus **Aa** (erledigt — Julian 2026-06-07: Aa gewinnt gg. aa) | — |
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — |
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — |
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx |
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)*
---
## F. Was hängt woran (Kurzübersicht)
| Funktion | Status | Wartet auf |
|---|---|---|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
---
## ✅ Erledigt (Archiv — nichts geht verloren)
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
- **C1C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
- **D1D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*

View File

@@ -1,4 +1,4 @@
# GerbilManager Produktionskonfiguration
# GerbilManager Produktionskonfiguration
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
@@ -7,28 +7,23 @@ POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80)
PORT=80
# Container Registry (git.rismer.de/gulum)
REGISTRY=git.rismer.de/gulum
# Gitea Container Registry (Standard: truenas:13000/gulum)
REGISTRY=truenas:13000/gulum
TAG=latest
# 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
# 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
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
KEYS_PATH=/mnt/SSD/gerbil/keys
# Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7
# WEB-2: Oeffentliche Webseite (Shared Volume: api schreibt, publicsite-nginx liest)
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
# Port fuer den publicsite-nginx (Julian's externer nginx leitet darauf weiter)
PUBLICSITE_PORT=8081
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
# Beliebiger OpenAI-kompatibler Anbieter — Optionen in docs/ai-provider.md
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
AI__BaseUrl=
AI__ApiKey=
AI__Model=gemini-flash-latest
AI__Model=gemini-2.0-flash

View File

@@ -1,4 +1,4 @@
# GerbilManager — TrueNAS Custom Application
# GerbilManager — TrueNAS Custom Application
# ==============================================
# Vor dem ersten Start:
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
@@ -29,7 +29,7 @@ services:
# --- .NET API (GerbilManagerWebAPI) ---
api:
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
build:
context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile
@@ -47,13 +47,10 @@ services:
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
AI__BaseUrl: "${AI__BaseUrl:-}"
AI__ApiKey: "${AI__ApiKey:-}"
AI__Model: "${AI__Model:-gemini-flash-latest}"
# WEB-2: Pfad wo POST /api/publish die oeffentliche Seite hinschreibt
PublicSite__RootPath: /data/publicsite
AI__Model: "${AI__Model:-gemini-2.0-flash}"
volumes:
- photos:/data/photos
- keys:/data/keys
- publicsite:/data/publicsite
depends_on:
db:
condition: service_healthy
@@ -66,7 +63,7 @@ services:
# --- nginx Frontend (React SPA + API-Proxy) ---
frontend:
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build:
context: ../..
dockerfile: gerbil-manager-web/Dockerfile
@@ -77,21 +74,6 @@ services:
api:
condition: service_healthy
# --- nginx Public Site (WEB-2) ---
# Serviert NUR die statische oeffentliche Seite (live/ aus dem publicsite-Volume).
# SICHERHEIT: Kein Proxy auf api/frontend — nur statisches HTML nach aussen.
# Julian's externer nginx-Proxy leitet <DOMAIN> auf Port 8081 weiter.
publicsite:
image: nginx:alpine
restart: unless-stopped
ports:
- "${PUBLICSITE_PORT:-8081}:80"
volumes:
- publicsite:/usr/share/nginx/html:ro
- ./nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
backup:
image: postgres:17-alpine
@@ -113,19 +95,19 @@ services:
volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
pgdata:
driver: local
driver_opts:
type: none
o: bind
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
photos:
driver: local
driver_opts:
type: none
o: bind
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys:
@@ -133,17 +115,10 @@ volumes:
driver_opts:
type: none
o: bind
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
backups:
driver: local
driver_opts:
type: none
o: bind
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
publicsite:
driver: local
driver_opts:
type: none
o: bind
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"

View File

@@ -1,29 +0,0 @@
# GerbilManager — publicsite nginx (WEB-2)
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
server {
listen 80;
root /usr/share/nginx/html/live;
index index.html;
charset utf-8;
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
location / {
try_files $uri $uri/index.html =404;
add_header Cache-Control "no-cache, must-revalidate";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "SAMEORIGIN";
}
# CSS/Bilder: kurze TTL (1 Tag)
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
try_files $uri =404;
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
# Kein Zugriff auf Staging-Verzeichnisse
location ~ ^/_(staging_new|old)/ {
return 403;
}
}

View File

@@ -1,37 +0,0 @@
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
#
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
# er hat keine Authentifizierung und muss LAN-only bleiben.
server {
listen 80;
server_name <DOMAIN>;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Kein Buffering fuer kleine statische HTML-Seiten
proxy_buffering off;
}
}
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
# server {
# listen 443 ssl;
# server_name <DOMAIN>;
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
# location / {
# proxy_pass http://127.0.0.1:8081;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }

View File

@@ -1,4 +1,4 @@
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|---|---|---|---|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
```powershell
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
$env:AI__ApiKey = '<schlüssel>'
$env:AI__Model = 'gemini-flash-latest'
$env:AI__Model = 'gemini-2.0-flash'
```
oder per user-secrets im API-Projekt:
@@ -46,5 +46,5 @@ services:
environment:
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
AI__Model: gemini-flash-latest
AI__Model: gemini-2.0-flash
```

View File

@@ -1,4 +1,4 @@
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
# GerbilManager — Betriebsanleitung (TrueNAS)
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
@@ -9,12 +9,13 @@
1. [Übersicht & Architektur](#1-übersicht--architektur)
2. [Voraussetzungen](#2-voraussetzungen)
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
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. [Fehlerbehebung](#8-fehlerbehebung)
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
9. [Fehlerbehebung](#9-fehlerbehebung)
---
@@ -22,7 +23,7 @@
```
Browser / Handy
| HTTP :80 (oder PORT aus .env, z.B. 8080)
| HTTP :80
v
┌──────────────────┐
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
@@ -39,10 +40,9 @@ Browser / Handy
│ db (Postgres 17)│ │ backup (Sidecar) │
└──────────────────┘ │ pg_dump + tar + cron │
│ └──────────────────────┘
└─ 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
└─ pgdata-Volume (NAS-Dataset)
photos-Volume (NAS-Dataset)
backups-Volume (NAS-Dataset)
```
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
@@ -54,107 +54,68 @@ Alles andere läuft intern im Docker-Netz.
| Was | Details |
|-----|---------|
| 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) |
| 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) |
---
## 3. Erstinstallation auf TrueNAS Goldeye
## 3. Erstinstallation auf TrueNAS
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
### 3.1 ZFS-Datasets anlegen
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
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
```bash
# 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
# SSH in TrueNAS oder lokale Shell
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
cd /opt/gerbilmanager
```
### 3.4 Konfiguration anlegen
### 3.3 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
| 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
### 3.4 Images bauen und App starten
```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml build
docker compose -f deploy/truenas/compose.yaml up -d
```
Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen).
> **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
### 3.5 Prüfen
```bash
# Alle 4 Container laufen?
# Alle Container laufen?
docker compose -f deploy/truenas/compose.yaml ps
# API-Healthcheck (erwartet: {"status":"Healthy"})
curl -s http://localhost/api/health
# API-Healthcheck
curl http://localhost/api/health
# 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
# Webapp im Browser
http://<NAS-IP>/
```
---
@@ -164,7 +125,6 @@ http://<NAS-IP>/scalar
### Starten
```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d
```
@@ -174,17 +134,26 @@ docker compose -f deploy/truenas/compose.yaml up -d
docker compose -f deploy/truenas/compose.yaml down
```
### Aktualisieren (nach CI-Push auf main)
### Aktualisieren (nach `git push` auf main)
```bash
cd /opt/gerbilmanager
git pull
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml build
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
@@ -196,12 +165,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: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
```
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
/mnt/SSD/gerbil/backups/
2026-06-06_03-00/
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
photos_2026-06-06_03-00.tar.gz (Fotos)
backup.log (Protokoll)
```
@@ -215,68 +184,52 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
### Backup-Log prüfen
```bash
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
tail -50 /mnt/SSD/gerbil/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 Datenbankdaten und Fotos werden überschrieben!
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
**Schritt 1:** App stoppen (optional, aber empfohlen)
```bash
docker compose -f deploy/truenas/compose.yaml stop api frontend
```
**Schritt 2:** Restore ausführen
```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
# Neuestes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh
# 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
# Bestimmtes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh 2026-06-05_03-00
```
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 — Verifikation (Pflicht nach erstem Restore-Drill):**
**Schritt 4:** Prüfen
```bash
# 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
curl http://localhost/api/color-varieties | grep -c '"id"'
# Erwarteter Wert: 73
```
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
### Restore-Nachweis (Round-Trip-Test)
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
```
73 ColorVarieties vorhanden
→ DELETE 12 Zeilen → 61 verbleibend
→ psql < dump.sql eingespielt
→ pg_restore eingespielt
→ 73 ColorVarieties bestätigt
Exit-Code: 0
```
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
---
@@ -287,15 +240,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
### Empfohlene Snapshot-Konfiguration
In TrueNAS → **Datasets**`JailStorage/DockerVolumes/gerbilmanager`**Snapshots****Regelmäßige Snapshots**:
In TrueNAS → **Datasets**Dataset auswählen**Snapshots****Regelmäßige Snapshots**:
| 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 |
| 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 |
> **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,
@@ -304,93 +257,94 @@ In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **
### Snapshot manuell erstellen (z. B. vor Update)
```bash
# 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)
# TrueNAS CLI
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
```
### Aus ZFS-Snapshot wiederherstellen (Fotos)
```bash
# Snapshots auflisten
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
# Snapshot auflisten
zfs list -t snapshot SSD/gerbil/photos
# Einzelne Datei aus Snapshot kopieren
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
# Datei aus Snapshot kopieren
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
```
---
## 7. CI/CD via Gitea Actions
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
`git.rismer.de/gulum/gerbilmanager-frontend`.
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
### Registry-Secrets in Gitea
### Aktivierung
Gitea → Repository → Einstellungen → Secrets:
1. **Gitea Actions aktivieren:**
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
| Secret | Wert |
|--------|------|
| `REGISTRY_USER` | Gitea-Benutzername |
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
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"
### Update nach CI-Push
3. **Registry-Secrets konfigurieren:**
Gitea → Repository → Einstellungen → Secrets:
- `REGISTRY_USER` — dein Gitea-Benutzername
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
```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
### 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
```
---
## 8. Fehlerbehebung
## 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
### 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"
```
@@ -398,16 +352,27 @@ docker compose -f deploy/truenas/compose.yaml exec db \
### Backup-Fehler
```bash
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
# 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
```
### 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

View File

@@ -1,136 +0,0 @@
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
> **Zielgruppe:** Julian.
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
---
## Architektur
```
Internet
| HTTPS/HTTP
v
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
| proxy_pass http://127.0.0.1:8081
v
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
| (Shared Volume, read-only)
| [POST /api/publish im Manager schreibt in dasselbe Volume]
v
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
| (Shared Volume, read-write)
v
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
```
**SICHERHEIT — harte Bedingung:**
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
---
## Erstinstallation
### 1. Verzeichnis anlegen
```bash
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
```
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
### 2. .env erganzen
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
```env
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
PUBLICSITE_PORT=8081
```
### 3. Compose-Stack neu starten
```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d
```
Der neue `publicsite`-Container startet und serviert Port 8081.
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
### 4. Julians externen nginx konfigurieren
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
(als eigenen `server`-Block oder per `include`):
```bash
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
nano /etc/nginx/conf.d/gerbilmanager-public.conf
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
nginx -t && nginx -s reload
```
---
## Seite veroeffentlichen (Publish-Ablauf)
1. Im Manager einloggen (http://\<NAS-IP\>/)
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
**Was passiert intern:**
```
POST /api/publish
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
→ Schreibt Dateien nach /data/publicsite/_staging_new/
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
→ Kein Container-Restart, kein Image-Rebuild, kein CI
Response: { "filesPublished": N }
```
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
---
## Verifikation
```bash
# publicsite-Container laeuft?
docker compose -f deploy/truenas/compose.yaml ps publicsite
# Seite lokal abrufbar?
curl -s http://localhost:8081/ | head -5
# live/-Verzeichnis gefuellt?
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
# Oeffentlich erreichbar (nach DNS-Propagation)?
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
```
---
## Sicherheitstrennung (Pflichtcheck)
| Was | Port | Internet-exponiert? |
|-----|------|---------------------|
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
Sie sind ausschliesslich im Heimnetz erreichbar.
---
## Hostname noch ausstehend
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.

View File

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
@@ -19,8 +19,9 @@ test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Marki
await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// DESIGN (Rennmaus Filter): „Externe Ahnen" ist ein Toggle-Chip (Button), kein Checkbox.
await page.getByRole('button', { name: t.filters.showExternalShort }).click()
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
await expect(maxRow).toBeVisible()

View File

@@ -1,79 +0,0 @@
/** FARBSCHLAG-GENOTYPE-SYNC: Bidirektionale Sync im Tier-Formular. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test('Farbschlag-Auswahl füllt Gencode automatisch aus', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Vor der Auswahl ist das Gencode-Feld leer.
await expect(genotypeInput).toHaveValue('')
// 'Agouti' wählen → Gencode wird automatisch befüllt.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(genotypeInput).toHaveValue('AA CC DD EE GG PP spsp rere')
})
test('Gencode-Eingabe aktualisiert die Farbschlag-Auswahl', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Gencode für Schwarz eintippen → Farbschlag-Select springt auf 'Schwarz'.
await genotypeInput.fill('aa CC DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('cv-schwarz')
})
test('Gencode ohne passende Farbschlag-Auswahl leert das Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
// Zuerst eine bekannte Farbe wählen, damit das Select belegt ist.
await farbschlagSelect.selectOption({ label: 'Agouti' })
await expect(farbschlagSelect).not.toHaveValue('')
// Marder-Genotyp: bekannte Farbe im Engine, aber NICHT im Mock-Dropdown
// → Select wird auf '' (kein Eintrag) zurückgesetzt.
await genotypeInput.fill('aa cchmcchm DD EE GG PP spsp rere')
await expect(farbschlagSelect).toHaveValue('')
})
test('Unbekannter Genotyp leert das Farbschlag-Dropdown (Unbekannt-Fall)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const farbschlagSelect = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.colorVariety}")`) })
.locator('select')
const genotypeInput = page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.genotype}")`) })
.locator('input')
await farbschlagSelect.selectOption({ label: 'Blau' })
await expect(farbschlagSelect).not.toHaveValue('')
// aa CC dd EE gg pp → Unbekannter Farbschlag (nicht im Katalog) → Select leert sich.
await genotypeInput.fill('aa CC dd EE gg pp spsp rere')
await expect(farbschlagSelect).toHaveValue('')
})

View File

@@ -1,54 +1,85 @@
/**
* DESIGN (Rennmaus Filter.html): Quick-Chips-Filter der Rennmäuse-Liste.
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
*
* Eine Pillen-Reihe (Status / Geschlecht / Farbschlag / Herkunft) öffnet je ein
* Bottom-Sheet mit Mehrfachauswahl; gewählte Werte erscheinen als entfernbare
* Chips, „Alle löschen" setzt zurück. Mobile-first; identisches Verhalten auf Desktop.
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
*/
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test } from './fixtures'
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test.describe('Quick-Chips-Filter Rennmäuse-Liste', () => {
test('Kategorie-Pillen sind sichtbar', async ({ page }) => {
test.describe('FilterPanel Rennmäuse-Liste', () => {
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
const quickrow = page.locator('.rmf-quickrow')
await expect(quickrow.getByRole('button', { name: t.filters.status })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.filters.gender })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.filters.colorVariety })).toBeVisible()
await expect(quickrow.getByRole('button', { name: t.fields.origin })).toBeVisible()
// Toggle-Button sichtbar.
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeVisible()
// Status-Beschriftung im Drawer ist noch verborgen.
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Mehrfachauswahl erzeugt entfernbare Chips, „Alle löschen" setzt zurück', async ({
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
page,
}) => {
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Default: Zucht-Chip ist aktiv.
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
// Öffnen → Status-Feld wird sichtbar.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
// Geschlecht = männlich über das Sheet wählen → zweiter Chip erscheint.
await selectListFilter(page, t.filters.gender, t.genderLabels.male)
await expect(page.locator('.rmf-chip-val', { hasText: t.genderLabels.male })).toBeVisible()
// „Alle löschen" entfernt sämtliche Chips.
await page.getByRole('button', { name: de.filterBar.clearAll }).click()
await expect(page.locator('.rmf-chip')).toHaveCount(0)
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Einzelner Chip lässt sich per Klick entfernen', async ({ page }) => {
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
skipUnlessMock()
await gotoSection(page, de.nav.gerbils)
if (testInfo.project.name !== 'phone') return
const breedingChip = page.locator('.rmf-chip', {
has: page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding }),
})
await expect(breedingChip).toBeVisible()
await breedingChip.click()
await expect(breedingChip).toHaveCount(0)
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
await expect(toggle).toHaveText('Filter')
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
await toggle.click()
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
await expect(toggle).toHaveText('Filter (1)')
// Zurücksetzen → Badge weg.
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
await expect(toggle).toHaveText('Filter')
})
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kein Toggle-Button auf Desktop (display:none via Media Query).
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeHidden()
// Status-Beschriftung direkt sichtbar.
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
})
})

View File

@@ -86,18 +86,6 @@ export async function openFilterPanel(page: Page) {
}
}
/**
* DESIGN (Rennmaus Filter): wählt einen Wert im Quick-Chips-Filter der Rennmäuse-Liste.
* Öffnet das Bottom-Sheet der Kategorie, hakt die Option an und schließt mit „Fertig".
*/
export async function selectListFilter(page: Page, categoryLabel: string, optionLabel: string) {
// Pille der Kategorie in der Quick-Chips-Reihe öffnen (nicht den aktiven Chip treffen).
await page.locator('.rmf-quickrow').getByRole('button', { name: categoryLabel }).click()
const sheet = page.getByRole('dialog', { name: categoryLabel })
await sheet.getByRole('button', { name: optionLabel, exact: true }).click()
await sheet.getByRole('button', { name: de.filterBar.done }).click()
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -1,45 +0,0 @@
/** LITTER-JUVENILE-DETAILS + JUVENILE-AVATARS: Jungtier-Felder und Foto-Avatare im Wurf-Detail. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.pages.litters
test('Jungtier-Liste zeigt Farbschlag, Abgabedatum, Abnehmer, Todesdatum und Todesursache', async ({
page,
}) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Krümel: Active, colorVarietyId cv-agouti → Farbschlag 'Agouti'
const kruemelCard = page.getByRole('link', { name: /Krümel/ })
await expect(kruemelCard).toBeVisible()
await expect(kruemelCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Agouti`)
// Pippa: GivenAway, Gold, goHomeDate 2025-05-01, Abnehmer Familie Huber
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard).toBeVisible()
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.colorVariety}: Gold`)
await expect(pippaCard).toContainText(t.detail.juvenileFields.goHomeDate)
await expect(pippaCard).toContainText(`${t.detail.juvenileFields.receiver}: Familie Huber`)
// Benny: Deceased, no colorVarietyId, dateOfDeath + causeOfDeath
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard).toBeVisible()
await expect(bennyCard).toContainText(t.detail.juvenileFields.dateOfDeath)
await expect(bennyCard).toContainText(`${t.detail.juvenileFields.causeOfDeath}: Altersschwäche`)
})
test('JUVENILE-AVATARS: Pippa zeigt Foto-Avatar, Benny zeigt GerbilIcon', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Pippa hat profilePhotoUrl → <img.juvenile-photo__avatar> sichtbar
const pippaCard = page.getByRole('link', { name: /Pippa/ })
await expect(pippaCard.locator('.juvenile-photo__avatar')).toBeVisible()
// Benny hat kein Foto → kein img, SVG-Icon (GerbilIcon) sichtbar
const bennyCard = page.getByRole('link', { name: /Benny/ })
await expect(bennyCard.locator('.juvenile-photo__avatar')).not.toBeVisible()
await expect(bennyCard.locator('svg')).toBeVisible()
})

View File

@@ -1,46 +0,0 @@
/** LITTER-MORTALITY: deathsWithin8Weeks im Wurf-Formular und -Detail. */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.litters
test('Wurf-Detail zeigt Frühverluste wenn gesetzt', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
// Frühverluste-Zeile im def-list: Label + Wert '1'
const mortalityRow = page.locator('.def-row', {
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
})
await expect(mortalityRow).toBeVisible()
await expect(mortalityRow.locator('dd')).toHaveText('1')
})
test('Wurf-Formular speichert Frühverluste und zeigt sie im Detail', async ({ page }) => {
skipUnlessMock()
const name = uniqueName('WurfM')
await page.goto('/wuerfe/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-10')
// Frühverluste eingeben
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('2')
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).not.toBeVisible()
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
// Frühverluste-Zeile im Detail
const mortalityRow = page.locator('.def-row', {
has: page.locator('dt', { hasText: t.fields.deathsWithin8Weeks }),
})
await expect(mortalityRow).toBeVisible()
await expect(mortalityRow.locator('dd')).toHaveText('2')
})
test('Frühverluste > Wurfstärke zeigt Warnmeldung', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/neu')
await page.getByLabel(t.fields.totalBorn).fill('3')
await page.getByLabel(t.fields.deathsWithin8Weeks).fill('5')
await expect(page.getByText(t.validation.deathsExceedTotalBorn)).toBeVisible()
})

View File

@@ -361,26 +361,6 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 201, contract)
}
// FEAT-NAMEGEN: /names/suggest
if (path === '/names/suggest' && method === 'GET') {
if (!db.namesConfigured) {
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
}
const letter = url.searchParams.get('letter')?.toUpperCase()
const allSuggestions = [
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
]
const result = letter
? allSuggestions.filter((s) => s.name.startsWith(letter))
: allSuggestions
return json(route, 200, result)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -74,8 +74,6 @@ export interface MockDb {
// ABGABE: Verträge + KI-Inserat-Flag
contracts: MockContract[]
saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean
}
function gerbil(
@@ -91,7 +89,7 @@ function gerbil(
id,
name,
gender,
status: 'Breeding',
status: 'Active',
dateOfBirth,
dateOfDeath: null,
causeOfDeath: null,
@@ -108,7 +106,6 @@ function gerbil(
notes: null,
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
isResident: true,
profilePhotoUrl: null,
}
}
@@ -121,7 +118,7 @@ export function seedDb(): MockDb {
originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten',
},
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
@@ -152,27 +149,12 @@ export function seedDb(): MockDb {
enclosureId: 'enc-leer',
notes: 'neugieriger Entdecker',
},
// LITTER-JUVENILE-DETAILS: Jungtiere für Wurf K mit Zusatzinfos.
// JUVENILE-AVATARS: Pippa hat profilePhotoUrl (1×1-PNG data-URI) → Avatar-Img sichtbar.
{
...gerbil('pup-abgabe', 'Pippa', 'female', '2025-03-12', 'w-kruemel', 'cv-gold'),
status: 'GivenAway' as const,
goHomeDate: '2025-05-01',
receiverContactId: 'con-huber',
profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
},
{
...gerbil('pup-verstorben', 'Benny', 'male', '2025-03-12', 'w-kruemel', null),
status: 'Deceased' as const,
dateOfDeath: '2025-04-15',
causeOfDeath: 'Altersschwäche',
},
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
]
const litters: Litter[] = [
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' },
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -310,6 +292,5 @@ export function seedDb(): MockDb {
mailConfigured: true,
contracts: [],
saleAdConfigured: true,
namesConfigured: true,
}
}

View File

@@ -1,59 +0,0 @@
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const t = de.namegen
const tf = de.pages.gerbils.form
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
// Panel öffnen
await page.getByRole('button', { name: t.button }).click()
await expect(page.getByText(t.panelTitle)).toBeVisible()
// Vorschläge laden
await page.getByRole('button', { name: t.loadButton }).click()
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
})
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
// Klick auf Vorschlag 'Fenrir'
await page.getByRole('button', { name: 'Fenrir' }).click()
// Panel geschlossen, Name-Feld befüllt
await expect(page.getByText(t.panelTitle)).toBeHidden()
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
})
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByLabel(t.letterLabel).fill('F')
await page.getByRole('button', { name: t.loadButton }).click()
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
// Artemis (A) nicht sichtbar
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
})
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
skipUnlessMock()
if (mockDb) mockDb.namesConfigured = false
await page.goto('/rennmaeuse/neu')
await page.getByRole('button', { name: t.button }).click()
await page.getByRole('button', { name: t.loadButton }).click()
await expect(page.getByText(t.keyMissing)).toBeVisible()
})

View File

@@ -3,7 +3,7 @@ import { de, expect, gotoSection, test } from './fixtures'
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
await page.goto('/')
// DESIGN: the top "Rennmaus-Manager" title bar was removed — the page heading remains.
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
await gotoSection(page, de.nav.gerbils)

View File

@@ -1,169 +0,0 @@
/**
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
*
* Abgedeckte Änderungen:
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
*
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
*/
import { de, expect, skipUnlessLive, test } from './fixtures'
const API = 'http://localhost:5179'
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
skipUnlessLive()
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
// eef = e/ef het → E>e>ef Rank → ee[f]
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
await expect(genoCode).toBeVisible({ timeout: 8_000 })
// Rohes 'eef' darf nicht mehr im Code-Block stehen
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
})
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
skipUnlessLive()
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
await expect(genoCode).toBeVisible({ timeout: 8_000 })
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
})
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
skipUnlessLive()
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
// Ccchm → Cc[chm]; eef → ee[f]
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
const genoCode = page.locator('code')
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
// Rohes Storage-Format darf nicht sichtbar sein
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
})
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
page,
}) => {
skipUnlessLive()
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
await expect(originInput).toBeVisible()
await expect(originInput).toHaveValue('Kleinen Chaoten')
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
await expect(deafLabel).toBeVisible()
// Dropdown-Select im Label-Kontext
const deafSelect = deafLabel.locator('select')
await expect(deafSelect).toBeVisible()
})
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
skipUnlessLive()
// Snapshot VORHER
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
const snap = await before.json()
expect(snap.originBreeder).toBe('Kleinen Chaoten')
expect(snap.genotype).toBeTruthy()
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
data: {
name: snap.name,
gender: snap.gender,
status: snap.status,
isResident: snap.isResident,
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
},
})
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
expect(res.status()).toBe(204)
// Re-Fetch und vergleichen
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
const updated = await afterRes.json()
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
})
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
skipUnlessLive()
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
await page.goto('/rennmaeuse')
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
const placeholder = page.getByText('(ohne Namen)')
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
if (await toggle.isVisible()) await toggle.click()
}
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
})
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
skipUnlessLive()
// Stammbaum eines Tiers mit namenlosem Elternteil
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
const placeholder = page.getByText('(ohne Namen)')
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
if (visible) {
await expect(placeholder.first()).toBeVisible()
}
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
})
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
skipUnlessLive()
await page.goto('/becken')
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
})
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
skipUnlessLive()
await page.goto('/')
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
await expect(
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
).toBeVisible()
})

View File

@@ -54,23 +54,6 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
})
test('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
const nameLink = page.getByRole('link', { name: 'Fridolin' })
await expect(nameLink).toBeVisible()
// href korrekt gesetzt
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
await nameLink.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
})
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
@@ -79,95 +62,3 @@ test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', a
// Karte selbst zeigt '(ohne Namen)' statt leer
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
})
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const hints = page.locator('.stammbaum-hints')
await expect(hints).toBeVisible()
await expect(hints).toContainText(t.tapHint)
await expect(hints).toContainText(t.hintName)
await expect(hints).toContainText(t.hintExpand)
})
test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Einpassen, damit die 4. Generation (Emil mit ) im Viewport liegt.
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// -Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
const expandBtn = page.getByRole('button', { name: t.expand }).first()
await expect(expandBtn).toBeVisible()
// Klick auf : Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
await expandBtn.click({ force: true })
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
await page.goto('/rennmaeuse/fridolin/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const panel = page.locator('.stammbaum-litters-panel')
await expect(panel).toBeVisible()
await expect(panel).toContainText(t.littersTitle)
await expect(panel).toContainText('Wurf K')
await expect(panel).toContainText('5')
// Link-Klick → Wurf-Detailseite
const wurfLink = panel.getByRole('link', { name: /Wurf K/ })
await expect(wurfLink).toBeVisible()
await wurfLink.click()
await expect(page).toHaveURL(/\/wuerfe\/w-kruemel/)
})
test('Kein Würfe-Panel wenn Wurzeltier keine Würfe hat (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat noch keine Würfe als Elternteil → Panel muss fehlen.
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.stammbaum-litters-panel')).not.toBeVisible()
})
test('Knoten-Karte zeigt volles Geburtsdatum (STAMMBAUM-FULLDATE)', async ({ page }) => {
skipUnlessMock()
// Krümel: dateOfBirth '2025-03-12' → formatDate → '12.03.2025'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
await expect(page.locator('.pedigree-card__year').first()).toContainText('12.03.2025')
})
test('Gencode-Chip zeigt Genotyp statt Farbname (STAMMBAUM-GENCODE)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist Vater von Krümel — genotype 'aa CC DD EE GG PP spsp rere'
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('.pedigree-chip')).toContainText('aa CC')
})
test('Tier mit Foto zeigt Avatar-Bild, Tier ohne Foto zeigt Rennmaus-Icon (STAMMBAUM-AVATAR)', async ({ page }) => {
skipUnlessMock()
// Fridolin hat profilePhotoUrl (data URI) → <img.pedigree-card__avatar> sichtbar
// Krümel hat profilePhotoUrl=null → kein img, SVG-Icon sichtbar
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Fridolin ist Vater von Krümel → seine Karte enthält ein <img>
const fridolinCard = page.locator('.pedigree-card').filter({ hasText: 'Fridolin' })
await expect(fridolinCard.locator('img.pedigree-card__avatar')).toBeVisible()
// Krümel (root) hat kein Foto → SVG-Icon im Foto-Slot
const rootCard = page.locator('.pedigree-card.pedigree-card--root')
await expect(rootCard.locator('img.pedigree-card__avatar')).not.toBeAttached()
await expect(rootCard.locator('svg[role="img"]')).toBeVisible()
})

View File

@@ -1,56 +0,0 @@
/** STATUS-MODEL: Zucht/Liebhaber wählbar; Verstorben/Abgegeben abgeleitet (read-only). */
import { de, expect, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
test('Tier-Formular zeigt nur wählbare Statuswerte im Dropdown', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/neu')
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
// Zucht (Breeding) ist vorausgewählt
await expect(statusField.locator('select')).toHaveValue('Breeding')
// Optionen: nur Zucht, Liebhaber, Abzugeben (NICHT Verstorben/Abgegeben)
const opts = statusField.locator('select option')
await expect(opts.filter({ hasText: t.statusLabels.Breeding })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Pet })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.ForSale })).toHaveCount(1)
await expect(opts.filter({ hasText: t.statusLabels.Deceased })).toHaveCount(0)
await expect(opts.filter({ hasText: t.statusLabels.GivenAway })).toHaveCount(0)
})
test('Tier-Formular zeigt abgeleiteten Status Verstorben als Text + Hinweis', async ({ page }) => {
skipUnlessMock()
// Willi ist Deceased
await page.goto('/rennmaeuse/willi/bearbeiten')
// Status-Select ist NICHT vorhanden (abgeleitet)
const statusField = page.locator('.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await expect(statusField).toBeVisible()
await expect(statusField.locator('select')).toHaveCount(0)
await expect(statusField).toContainText(t.statusLabels.Deceased)
await expect(statusField).toContainText(t.form.statusDerivedHint.Deceased)
})
test('Tiere-Liste zeigt Zucht als Standard-Status-Filter', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse')
// DESIGN (Rennmaus Filter): der Default-Status erscheint als aktiver, entfernbarer Chip.
await expect(page.locator('.rmf-chip-val', { hasText: t.statusLabels.Breeding })).toBeVisible()
})
test('Neues Tier anlegen mit Status Liebhaber', async ({ page }) => {
skipUnlessMock()
const name = uniqueName('Tier')
await page.goto('/rennmaeuse/neu')
await page.getByLabel(`${t.fields.name} *`).fill(name)
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.male })
const statusField = page.locator('label.field', {
has: page.locator(`span:text-is("${t.fields.status}")`),
})
await statusField.locator('select').selectOption({ label: t.statusLabels.Pet })
await page.getByRole('button', { name: t.form.save, exact: true }).click()
await expect(page.getByRole('heading', { name })).toBeVisible()
})

View File

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils
const tabs = de.pages.tierTabs
@@ -23,9 +23,13 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
// DESIGN (Rennmaus Filter): Herkunft (originBreeder) über das Quick-Chips-Sheet
// auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel) filtern.
await selectListFilter(page, t.fields.origin, 'Clan-Kleine-Chaoten')
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
.locator('select')
.selectOption({ label: 'Clan-Kleine-Chaoten' })
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
@@ -168,27 +172,3 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
await page.goto('/rennmaeuse/kruemel/bearbeiten')
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
})
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
await page.goto('/rennmaeuse/fridolin')
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
// Wurf K als Link sichtbar
const litterLink = page.getByRole('link', { name: /Wurf K/ })
await expect(litterLink).toBeVisible()
// Vater-Badge sichtbar
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
// Navigation zum Wurf funktioniert
await litterLink.first().click()
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
})
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
skipUnlessMock()
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
})

View File

@@ -5,13 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Verwaltung für Rennmäuse, Würfe und Züchter" />
<!-- DESIGN (Rennmaus Filter): Hanken Grotesk als App-Schrift. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>Rennmaus-Manager</title>
</head>
<body>

View File

@@ -1,78 +0,0 @@
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
import { describe, expect, it } from 'vitest'
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
describe('buildSuggestPath', () => {
it('includes uppercased letter', () => {
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
expect(path).toContain('letter=F')
})
it('omits letter when blank', () => {
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('omits letter when only whitespace', () => {
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
expect(path).not.toContain('letter=')
})
it('includes gender', () => {
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
expect(path).toContain('gender=male')
})
it('omits gender for empty string', () => {
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
expect(path).not.toContain('gender=')
})
it('joins multiple usages without encoding commas', () => {
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
expect(path).toContain('usages=norn,mythg')
})
it('omits usages when array is empty', () => {
const path = buildSuggestPath({ usages: [] })
expect(path).not.toContain('usages=')
})
it('defaults count to 6', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toContain('count=6')
})
it('uses provided count', () => {
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
expect(path).toContain('count=8')
})
it('starts with /names/suggest', () => {
const path = buildSuggestPath({ usages: ['norn'] })
expect(path).toMatch(/^\/names\/suggest\?/)
})
})
describe('NAMEGEN_USAGES', () => {
it('contains all 5 expected culture codes', () => {
const codes = NAMEGEN_USAGES.map((u) => u.code)
expect(codes).toContain('norn')
expect(codes).toContain('japa')
expect(codes).toContain('mythg')
expect(codes).toContain('ger')
expect(codes).toContain('arb')
expect(codes).toContain('disney')
expect(codes).toContain('pokemon')
expect(codes).toContain('encities')
expect(codes).toContain('hrcities')
expect(codes).toContain('usstates')
expect(codes).toHaveLength(10)
})
it('every usage has a non-empty label', () => {
for (const u of NAMEGEN_USAGES) {
expect(u.label.length).toBeGreaterThan(0)
}
})
})

View File

@@ -61,23 +61,6 @@ export function andFilter(...conditions: Array<string | null | undefined | false
return conditions.filter((c): c is string => Boolean(c)).join(',')
}
/**
* Build an OR-group of conditions for one field, e.g. `(status=Breeding|status=ForSale)`.
* Used for multi-select filters: several allowed values within one category are OR'd,
* while categories are AND'd together by {@link andFilter}.
* Returns '' for an empty value list (so andFilter drops it); a single value needs no parens.
*/
export function orGroup(
field: string,
values: ReadonlyArray<string | number | boolean>,
op: GridifyOp = '==',
): string {
const parts = values.map((value) => condition({ field, op, value }))
if (parts.length === 0) return ''
if (parts.length === 1) return parts[0]
return `(${parts.join('|')})`
}
/** Assemble the URL query string ("?...") from a GridifyQuery. */
export function toQueryString(q: GridifyQuery): string {
const params = new URLSearchParams()

View File

@@ -1,45 +0,0 @@
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
import { api } from './client'
export interface NameSuggestion {
name: string
meaning: string
origin: string
}
export const NAMEGEN_USAGES = [
{ code: 'norn', label: 'Nordisch' },
{ code: 'japa', label: 'Japanisch' },
{ code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' },
{ code: 'disney', label: 'Disney' },
{ code: 'pokemon', label: 'Pokémon' },
{ code: 'encities', label: 'Englische Städte' },
{ code: 'hrcities', label: 'Kroatische Städte' },
{ code: 'usstates', label: 'US-Bundesstaaten' },
] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
export interface SuggestNamesParams {
letter?: string
gender?: string
usages: NamegenUsageCode[]
count?: number
}
/** Exported for unit tests — builds the query path without a network call. */
export function buildSuggestPath(params: SuggestNamesParams): string {
const parts: string[] = []
const letter = params.letter?.trim().toUpperCase()
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
parts.push(`count=${params.count ?? 6}`)
return `/names/suggest?${parts.join('&')}`
}
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
return api.get<NameSuggestion[]>(buildSuggestPath(params))
}

View File

@@ -10,11 +10,9 @@
export type Gender = 'unknown' | 'male' | 'female'
export const GENDERS: Gender[] = ['unknown', 'male', 'female']
/** Gerbil.Status — C# enum { Breeding, Pet, Deceased, GivenAway, ForSale }. STATUS-MODEL: Active→Breeding (gated on Pam feature/status-model). */
export type GerbilStatus = 'Breeding' | 'Pet' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale', 'Deceased', 'GivenAway']
/** Statuses the user can pick freely in the form; Deceased+GivenAway are derived by the backend. */
export const SELECTABLE_STATUSES: GerbilStatus[] = ['Breeding', 'Pet', 'ForSale']
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale'
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway']
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
export type DateOnlyString = string
@@ -58,8 +56,6 @@ export interface Gerbil {
isResident?: boolean
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
isDeaf?: boolean | null
/** STAMMBAUM-AVATAR: URL des Profilfotos (erstes Foto nach sortOrder); null = kein Foto. API-relativ oder absolut. */
profilePhotoUrl?: string | null
}
/** Payload for POST /gerbils. */
@@ -126,8 +122,6 @@ export interface Litter {
notes?: string | null
fatherId: string | null
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null
}
/** Payload for POST /litters. */
@@ -139,7 +133,6 @@ export interface CreateLitter {
notes?: string | null
fatherId?: string | null
motherId?: string | null
deathsWithin8Weeks?: number | null
}
export type UpdateLitter = Partial<CreateLitter>

View File

@@ -1,21 +1,19 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de'
import GerbilIcon from './GerbilIcon'
import './appShell.css'
interface NavItem {
to: string
label: string
icon: ReactNode
icon: string
end?: boolean
}
// Primary items are always visible in the phone bottom bar; secondary items
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
const PRIMARY: NavItem[] = [
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: <GerbilIcon size="1.25em" /> },
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
]
@@ -47,6 +45,11 @@ export default function AppShell() {
return (
<div className="app-shell">
<header className="app-header">
<span className="app-logo" aria-hidden="true">🐭</span>
<h1 className="app-title">{de.app.title}</h1>
</header>
<nav className="app-nav" aria-label={de.nav.mainNavigation}>
<div className="nav-primary">
{PRIMARY.map((item) => (

View File

@@ -43,29 +43,16 @@ export default function BreedingResultView({ result, title }: BreedingResultView
) : (
<>
<ul className="farbschlag-cards">
{result.byFarbschlag.map((f) => {
// Representative genotype: highest-prob offspring for this farbschlag.
const repGenotype = result.offspring.find(
(o) => o.farbschlag === f.farbschlag,
)?.genotype
return (
<li key={f.farbschlag} className="farbschlag-card">
<div className="farbschlag-card__img">
<FarbschlagImage name={f.farbschlag} />
</div>
<div className="farbschlag-card__body">
<span className="farbschlag-card__name">{f.farbschlag}</span>
{repGenotype && (
<code className="farbschlag-card__geno">{repGenotype}</code>
)}
</div>
<span className="farbschlag-card__prob">
{f.probability.percent}
<small> ({f.probability.text})</small>
</span>
</li>
)
})}
{result.byFarbschlag.map((f) => (
<li key={f.farbschlag} className="farbschlag-card">
<FarbschlagImage name={f.farbschlag} />
<span className="farbschlag-card__name">{f.farbschlag}</span>
<span className="farbschlag-card__prob">
{f.probability.percent}
<small> ({f.probability.text})</small>
</span>
</li>
))}
</ul>
<button

View File

@@ -1,5 +1,5 @@
import { de } from '../strings/de'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
import { ALL_TRAITS } from '../format/traits'
import './charakterbogen.css'
export interface CharakterbogenProps {
@@ -10,6 +10,12 @@ export interface CharakterbogenProps {
onNoteChange: (note: string) => void
}
/**
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
* Controlled & reusable: rendered on the animal detail page (persisted) and in
* the Abgabe listing composer (feeds the AI sale-text). German labels from
* de.character.traits; the stored value is the trait KEY.
*/
export default function Charakterbogen({
traits,
note,
@@ -23,32 +29,26 @@ export default function Charakterbogen({
const next = new Set(selected)
if (next.has(key)) next.delete(key)
else next.add(key)
// Preserve vocabulary (category) order for stable output.
// Preserve the vocabulary order for stable output.
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
}
return (
<div className="charakterbogen">
{TRAIT_CATEGORIES.map((cat) => (
<section key={cat.category} className="trait-category">
<h4 className="trait-category__heading">{cat.category}</h4>
<ul className="trait-grid">
{cat.traits.map((tr) => (
<li key={tr.key}>
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
<input
type="checkbox"
checked={selected.has(tr.key)}
onChange={() => toggle(tr.key)}
/>
<span>{tr.label}</span>
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
</label>
</li>
))}
</ul>
</section>
))}
<ul className="trait-grid">
{ALL_TRAITS.map((tr) => (
<li key={tr.key}>
<label className="trait-chip">
<input
type="checkbox"
checked={selected.has(tr.key)}
onChange={() => toggle(tr.key)}
/>
<span>{tr.label}</span>
</label>
</li>
))}
</ul>
<label className="field">
<span>{t.noteLabel}</span>
<textarea

View File

@@ -1,271 +0,0 @@
/**
* DESIGN (Rennmaus Filter.html): mobile-first filter UX —
* 1. a horizontal "Quick-Chips" pill row (one pill per category, with a count badge),
* 2. tapping a pill opens a bottom-sheet of multi-select options (✓ checkboxes,
* searchable for long lists like Farbschlag / Herkunft),
* 3. active selections appear ABOVE the list as removable chips (so they scroll
* away with the list), with "Alle löschen".
*
* The user landed on the Quick-Chips concept, dezent style, brown accent. Colors
* come from the app's existing tokens. `GerbilFilterBar` is the pill row + sheet;
* `ActiveFilterChips` renders the removable chips and is placed above the list.
*/
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { de } from '../strings/de'
import './gerbilFilterBar.css'
export interface FilterOption {
value: string
label: string
/** Optional swatch dot (used for status colors). */
color?: string
/** Optional leading symbol (used for gender ♂ ♀ ?). */
symbol?: string
}
export interface FilterCategory {
/** Matches the Gridify field / the key into the selected map. */
key: string
label: string
/** Renders a search box above the option list (for long lists). */
searchable?: boolean
options: FilterOption[]
}
function countFor(selected: Record<string, string[]>, key: string): number {
return (selected[key] ?? []).length
}
interface GerbilFilterBarProps {
categories: FilterCategory[]
selected: Record<string, string[]>
onToggle: (key: string, value: string) => void
}
/** The Quick-Chips pill row + the category bottom-sheet. */
export function GerbilFilterBar({ categories, selected, onToggle }: GerbilFilterBarProps) {
const t = de.filterBar
const [activeKey, setActiveKey] = useState<string | null>(null)
const activeCat = categories.find((c) => c.key === activeKey) ?? null
return (
<div className="rmf">
<div className="rmf-quickrow" role="group" aria-label={t.title}>
{categories.map((cat) => {
const n = countFor(selected, cat.key)
return (
<button
key={cat.key}
type="button"
className={'rmf-pill' + (n > 0 ? ' is-on' : '')}
onClick={() => setActiveKey(cat.key)}
aria-haspopup="dialog"
>
{cat.label}
{n > 0 && <span className="rmf-pill-count">{n}</span>}
<span className="rmf-pill-chev" aria-hidden="true"></span>
</button>
)
})}
</div>
{activeCat && (
<FilterSheet
cat={activeCat}
selected={selected[activeCat.key] ?? []}
onToggle={onToggle}
onClose={() => setActiveKey(null)}
/>
)}
</div>
)
}
interface ActiveFilterChipsProps {
categories: FilterCategory[]
selected: Record<string, string[]>
onRemove: (key: string, value: string) => void
onClearAll: () => void
/** Optional per-chip accent colour (e.g. status colours); falls back to the app accent. */
chipColor?: (key: string, value: string) => string | undefined
}
/**
* Removable chips for the active selections, placed above the list so they scroll
* away as you browse. Wraps onto multiple lines — nothing is hidden.
*/
export function ActiveFilterChips({
categories,
selected,
onRemove,
onClearAll,
chipColor,
}: ActiveFilterChipsProps) {
const t = de.filterBar
const chips = categories.flatMap((cat) => {
const labelByValue = new Map(cat.options.map((o) => [o.value, o.label]))
return (selected[cat.key] ?? []).map((value) => ({
key: cat.key,
value,
catLabel: cat.label,
label: labelByValue.get(value) ?? value,
}))
})
if (chips.length === 0) return null
return (
<div className="rmf-chips">
{chips.map((c) => {
const accent = chipColor?.(c.key, c.value)
return (
<button
key={c.key + c.value}
type="button"
className="rmf-chip"
style={accent ? ({ '--chip': accent } as CSSProperties) : undefined}
onClick={() => onRemove(c.key, c.value)}
aria-label={`${c.catLabel}: ${c.label}${t.remove}`}
>
<span className="rmf-chip-cat">{c.catLabel}</span>
<span className="rmf-chip-val">{c.label}</span>
<span className="rmf-chip-x" aria-hidden="true">×</span>
</button>
)
})}
{chips.length > 1 && (
<button type="button" className="rmf-chip rmf-chip-clear" onClick={onClearAll}>
{t.clearAll}
</button>
)}
</div>
)
}
interface BottomSheetProps {
title: string
onClose: () => void
children: ReactNode
}
/**
* Reusable bottom-sheet: dimmed scrim + slide-up panel with grip, title and a
* "Fertig" button. Locks body scroll and closes on Escape / scrim tap. Used for
* the category filters AND the sort selector (same drawer mechanic).
*/
export function BottomSheet({ title, onClose, children }: BottomSheetProps) {
const t = de.filterBar
useEffect(() => {
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [onClose])
// Portal to <body> so the sheet escapes any sticky/stacking-context ancestor
// (e.g. the sticky header) and renders above the bottom action bar.
return createPortal(
<div className="rmf-scrim" onClick={onClose} role="presentation">
<div
className="rmf-sheet"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<div className="rmf-grip" aria-hidden="true" />
<div className="rmf-sheet-head">
<span className="rmf-sheet-title">{title}</span>
<button type="button" className="rmf-sheet-done" onClick={onClose}>
{t.done}
</button>
</div>
<div className="rmf-sheet-body">{children}</div>
</div>
</div>,
document.body,
)
}
interface FilterSheetProps {
cat: FilterCategory
selected: string[]
onToggle: (key: string, value: string) => void
onClose: () => void
}
function FilterSheet({ cat, selected, onToggle, onClose }: FilterSheetProps) {
return (
<BottomSheet title={cat.label} onClose={onClose}>
<OptionList cat={cat} selected={selected} onToggle={onToggle} />
</BottomSheet>
)
}
interface OptionListProps {
cat: FilterCategory
selected: string[]
onToggle: (key: string, value: string) => void
}
function OptionList({ cat, selected, onToggle }: OptionListProps) {
const t = de.filterBar
const [q, setQ] = useState('')
const options = useMemo(() => {
if (!cat.searchable) return cat.options
const term = q.trim().toLowerCase()
if (!term) return cat.options
return cat.options.filter((o) => o.label.toLowerCase().includes(term))
}, [cat, q])
return (
<div className="rmf-picker">
{cat.searchable && (
<input
className="rmf-search"
placeholder={t.searchPlaceholder}
value={q}
onChange={(e) => setQ(e.target.value)}
autoFocus
aria-label={t.searchPlaceholder}
/>
)}
<div className={'rmf-optlist' + (cat.searchable ? ' rmf-optlist--scroll' : '')}>
{options.length === 0 && <div className="rmf-empty">{t.noMatch}</div>}
{options.map((o) => {
const on = selected.includes(o.value)
return (
<button
key={o.value}
type="button"
className={'rmf-opt' + (on ? ' is-on' : '')}
onClick={() => onToggle(cat.key, o.value)}
aria-pressed={on}
>
<span className="rmf-opt-box" aria-hidden="true">
{on ? '✓' : ''}
</span>
{o.color && <span className="rmf-opt-dot" style={{ background: o.color }} />}
{o.symbol && (
<span className="rmf-opt-sym" aria-hidden="true">
{o.symbol}
</span>
)}
<span className="rmf-opt-label">{o.label}</span>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -1,37 +0,0 @@
import type { CSSProperties } from 'react'
interface Props {
size?: string | number
style?: CSSProperties
className?: string
}
/** Reusable gerbil silhouette icon — upright sitting pose, long tail with tuft. fill=currentColor. */
export default function GerbilIcon({ size = '1em', style, className }: Props) {
return (
<svg
viewBox="0 0 64 64"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
style={{ display: 'inline-block', verticalAlign: '-0.125em', flexShrink: 0, ...style }}
className={className}
role="img"
aria-label="Rennmaus"
>
{/* body — sitting upright */}
<path d="M 34 23 C 38 20, 44 21, 46 25 C 47 27, 45 30, 41 31 C 38 32, 40 38, 40 42 C 40 48, 36 51, 31 52 C 25 52, 21 47, 21 40 C 21 31, 27 25, 34 23 Z" />
{/* ears */}
<path d="M 36 22 Q 33 13 39 11 Q 42 13 39 21 Z" />
<path d="M 32 23 Q 30 15 35 13 Q 37 15 35 22 Z" opacity="0.6" />
{/* front paw */}
<path d="M 41 33 Q 46 33 46 36 Q 44 38 41 35 Z" />
{/* hind paw */}
<path d="M 21 49 C 19 53, 23 55, 30 55 C 33 55, 34 52, 30 50 Z" />
{/* tail and tuft */}
<path d="M 21 47 C 14 51, 9 46, 8 41 C 7 35, 11 27, 16 22 C 17 21, 18 22, 17 23 C 13 28, 10 35, 11 40 C 12 44, 15 47, 21 44 Z" />
<path d="M 16 22 C 16 16, 13 11, 16 7 C 20 3, 22 10, 19 16 C 18 19, 17 21, 16 22 Z" />
</svg>
)
}

View File

@@ -1,147 +0,0 @@
.namegen-name-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.namegen-name-row .input {
flex: 1;
min-width: 0;
}
.namegen-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-bg, #fff);
}
.namegen-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-weight: 600;
}
.namegen-panel__filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.namegen-panel__letter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-panel__letter-input {
width: 5rem;
}
.namegen-panel__gender {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-gender-group {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 0.4rem;
overflow: hidden;
}
.namegen-gender-btn {
background: none;
border: none;
border-left: 1px solid var(--color-border);
padding: 0.35rem 0.7rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
color: var(--color-text);
}
.namegen-gender-btn:first-child {
border-left: none;
}
.namegen-gender-btn.is-active {
background: var(--color-accent, #2563eb);
color: #fff;
}
.namegen-panel__usages {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.namegen-usages-grid {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
}
.namegen-usages-grid label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.9rem;
cursor: pointer;
}
.namegen-suggestions {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
border-top: 1px solid var(--color-border);
}
.namegen-suggestion {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.namegen-suggestion:last-child {
border-bottom: none;
}
.namegen-suggestion__pick {
font-weight: 600;
color: var(--color-accent, #2563eb);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
font-size: inherit;
font-family: inherit;
min-width: 6rem;
}
.namegen-suggestion__pick:hover {
text-decoration: underline;
}
.namegen-suggestion__meaning {
flex: 1;
font-size: 0.88rem;
color: var(--color-text-muted);
min-width: 8rem;
}
.namegen-suggestion__origin {
font-size: 0.85rem;
color: var(--color-text-muted);
white-space: nowrap;
}

View File

@@ -1,162 +0,0 @@
import { useState } from 'react'
import { de } from '../strings/de'
import { ApiError, errorCode } from '../api/client'
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
import './NameSuggestPanel.css'
interface NameSuggestPanelProps {
gender: string
onPick: (name: string) => void
onClose: () => void
}
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
type GenderSelection = 'any' | 'female' | 'male'
/** Map the gerbil form's gender onto the generator's three-way choice. */
function seedGender(gender: string): GenderSelection {
return gender === 'female' || gender === 'male' ? gender : 'any'
}
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
const t = de.namegen
const [letter, setLetter] = useState('')
const [genderSel, setGenderSel] = useState<GenderSelection>(() => seedGender(gender))
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
const [loading, setLoading] = useState(false)
const [keyMissing, setKeyMissing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
const [fetched, setFetched] = useState(false)
function toggleUsage(code: NamegenUsageCode) {
setUsages((prev) => {
const next = new Set(prev)
if (next.has(code)) next.delete(code)
else next.add(code)
return next
})
}
async function load() {
setLoading(true)
setError(null)
setKeyMissing(false)
setSuggestions([])
try {
const result = await suggestNames({
letter: letter.trim() || undefined,
gender: genderSel === 'any' ? undefined : genderSel,
usages: [...usages] as NamegenUsageCode[],
count: 6,
})
setSuggestions(result)
setFetched(true)
} catch (err) {
if (errorCode(err) === 'NamesKeyMissing') {
setKeyMissing(true)
} else {
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
}
} finally {
setLoading(false)
}
}
return (
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
<div className="namegen-panel__header">
<span>{t.panelTitle}</span>
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
</button>
</div>
<div className="namegen-panel__filters">
<div className="namegen-panel__letter">
<label htmlFor="namegen-letter">{t.letterLabel}</label>
<input
id="namegen-letter"
className="input namegen-panel__letter-input"
value={letter}
onChange={(e) => setLetter(e.target.value)}
placeholder={t.letterPlaceholder}
maxLength={1}
/>
</div>
<div className="namegen-panel__gender">
<span>{t.genderLabel}</span>
<div className="namegen-gender-group" role="radiogroup" aria-label={t.genderLabel}>
{([
['any', t.genderAny],
['female', t.genderFemale],
['male', t.genderMale],
] as const).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={genderSel === value}
className={`namegen-gender-btn ${genderSel === value ? 'is-active' : ''}`}
onClick={() => setGenderSel(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="namegen-panel__usages">
<span>{t.usagesLabel}</span>
<div className="namegen-usages-grid">
{NAMEGEN_USAGES.map(({ code, label }) => (
<label key={code}>
<input
type="checkbox"
checked={usages.has(code)}
onChange={() => toggleUsage(code)}
/>
{label}
</label>
))}
</div>
</div>
</div>
<button
type="button"
className="btn btn--primary"
onClick={load}
disabled={loading || usages.size === 0}
>
{loading ? t.loading : t.loadButton}
</button>
{keyMissing && <p className="muted">{t.keyMissing}</p>}
{error && <p className="error-text">{error}</p>}
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
<p className="muted">{t.empty}</p>
)}
{suggestions.length > 0 && (
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
{suggestions.map((s, i) => (
<li key={`${s.name}-${i}`} className="namegen-suggestion">
<button
type="button"
className="namegen-suggestion__pick"
onClick={() => onPick(s.name)}
>
{s.name}
</button>
<span className="namegen-suggestion__meaning">{s.meaning}</span>
<span className="namegen-suggestion__origin">{s.origin}</span>
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -1,21 +1,8 @@
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
.trait-category {
margin-bottom: 0.25rem;
}
.trait-category__heading {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-muted, #666);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0.75rem 0 0.35rem;
}
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
.charakterbogen .trait-grid {
list-style: none;
margin: 0 0 0.5rem;
margin: 0 0 0.75rem;
padding: 0;
display: grid;
grid-template-columns: 1fr;
@@ -52,16 +39,3 @@
min-height: 0;
flex: 0 0 auto;
}
.trait-chip--warn {
border-color: #d97706;
background: #fff7ed;
}
.trait-warn-badge {
margin-left: auto;
font-size: 0.68rem;
font-weight: 600;
color: #d97706;
white-space: nowrap;
}

View File

@@ -1,427 +0,0 @@
/* DESIGN (Rennmaus Filter.html): Quick-Chips + Bottom-Sheet + entfernbare Chips.
Mobile-first. Palette + font sizes are taken 1:1 from the design (the app tokens
are now the design's creme/brown/terracotta), so this matches the prototype. */
.rmf {
--rmf-ease: cubic-bezier(0.22, 1, 0.36, 1);
margin: 0.5rem 0 0.25rem;
}
/* ---------- Quick-Chips pill row ---------- */
.rmf-quickrow {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding: 2px 0 4px;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.rmf-quickrow::-webkit-scrollbar {
display: none;
}
.rmf-pill {
flex-shrink: 0;
cursor: pointer;
font: inherit;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text);
padding: 9px 13px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 0.375rem;
box-shadow: var(--shadow);
transition: transform 0.12s var(--rmf-ease), border-color 0.15s, color 0.15s, background 0.15s;
}
.rmf-pill:active {
transform: scale(0.94);
}
.rmf-pill.is-on {
border-color: var(--color-accent);
color: var(--color-accent);
background: var(--color-accent-soft);
}
.rmf-pill-count {
background: var(--color-accent);
color: #fff;
font-size: 11px;
font-weight: 800;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.rmf-pill-chev {
font-size: 11px;
color: var(--color-text-faint);
}
/* ---------- Active filter chips ---------- */
.rmf-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.625rem;
}
.rmf-chip {
--chip: var(--color-accent);
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: color-mix(in srgb, var(--chip) 12%, #fff);
border: 1px solid color-mix(in srgb, var(--chip) 32%, transparent);
color: color-mix(in srgb, var(--chip) 78%, #2b2723);
padding: 6px 8px 6px 11px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
animation: rmfChipIn 0.22s var(--rmf-ease);
transition: transform 0.1s;
}
.rmf-chip:active {
transform: scale(0.94);
}
.rmf-chip-cat {
font-size: 11px;
font-weight: 700;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.rmf-chip-val {
font-weight: 700;
}
.rmf-chip-x {
font-size: 15px;
line-height: 1;
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--chip) 22%, transparent);
border-radius: 50%;
}
.rmf-chip-clear {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.rmf-chip-clear .rmf-chip-x {
display: none;
}
@keyframes rmfChipIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ---------- Bottom-Sheet ---------- */
.rmf-scrim {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(43, 33, 25, 0.42);
display: flex;
align-items: flex-end;
justify-content: center;
animation: rmfScrim 0.2s ease;
}
@keyframes rmfScrim {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.rmf-sheet {
width: 100%;
max-width: 460px;
background: var(--color-bg);
border-radius: 22px 22px 0 0;
max-height: 82vh;
display: flex;
flex-direction: column;
box-shadow: 0 -8px 40px rgba(43, 33, 25, 0.25);
animation: rmfSheetUp 0.32s var(--rmf-ease);
}
@keyframes rmfSheetUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.rmf-grip {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--color-text-faint);
opacity: 0.5;
margin: 9px auto 4px;
}
.rmf-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 1rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.rmf-sheet-title {
font-size: 18px;
font-weight: 800;
color: var(--color-text);
}
.rmf-sheet-done {
background: var(--color-accent);
color: #fff;
border: none;
font: inherit;
font-weight: 700;
font-size: 14px;
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
}
.rmf-sheet-body {
overflow-y: auto;
padding: 0.5rem 1rem 1.5rem;
-webkit-overflow-scrolling: touch;
}
/* ---------- Option list (multi-select checkboxes) ---------- */
.rmf-picker {
display: flex;
flex-direction: column;
}
.rmf-search {
width: 100%;
border: 1px solid var(--color-border);
background: #fff;
border-radius: 14px;
padding: 0.75rem 0.875rem;
font: inherit;
font-size: 1rem;
color: var(--color-text);
outline: none;
margin: 0.25rem 0 0.375rem;
}
.rmf-search:focus {
border-color: var(--color-accent);
}
.rmf-optlist {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 4px;
}
.rmf-optlist--scroll {
max-height: 46vh;
overflow-y: auto;
}
.rmf-opt {
display: flex;
align-items: center;
gap: 0.7rem;
background: none;
border: none;
font: inherit;
padding: 0.75rem 0.375rem;
cursor: pointer;
color: var(--color-text);
font-size: 1rem;
font-weight: 500;
text-align: left;
width: 100%;
border-radius: 10px;
transition: background 0.12s;
}
.rmf-opt:hover {
background: var(--color-accent-soft);
}
.rmf-opt.is-on {
font-weight: 700;
}
.rmf-opt-box {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 7px;
border: 1.5px solid var(--color-border);
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #fff;
font-weight: 800;
transition: background 0.14s, border-color 0.14s;
}
.rmf-opt.is-on .rmf-opt-box {
background: var(--color-accent);
border-color: var(--color-accent);
}
.rmf-opt-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.rmf-opt-sym {
width: 22px;
text-align: center;
font-size: 16px;
color: var(--color-text-muted);
}
.rmf-opt-label {
flex: 1;
}
.rmf-empty {
padding: 1.1rem 0.375rem;
color: var(--color-text-muted);
font-size: 15px;
}
/* ---------- Search bar ---------- */
.rmf-searchbar {
margin-top: 0.75rem;
position: relative;
display: flex;
align-items: center;
background: #fff;
border: 1px solid var(--color-border);
border-radius: 14px;
box-shadow: var(--shadow);
}
.rmf-search-ico {
padding-left: 0.875rem;
font-size: 19px;
color: var(--color-text-faint);
}
.rmf-searchfield {
flex: 1;
border: none;
background: transparent;
outline: none;
font: inherit;
font-size: 16px;
color: var(--color-text);
padding: 0.875rem 0.75rem;
min-width: 0;
}
.rmf-searchfield::placeholder {
color: var(--color-text-faint);
}
.rmf-search-clear {
border: none;
background: var(--color-accent-soft);
color: var(--color-text-muted);
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 0.625rem;
font-size: 1rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
/* ---------- Bar: Externe-Ahnen toggle + Sortieren — two equal-width chips ---------- */
.rm-bar {
display: flex;
align-items: stretch;
gap: 0.5rem;
margin-top: 0.625rem;
}
/* Shared pill: both chips are the SAME size (flex: 1 → equal width). */
.rmf-bar-chip {
flex: 1 1 0;
min-width: 0;
cursor: pointer;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #fff;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
padding: 8px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
box-shadow: var(--shadow);
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.1s;
}
.rmf-bar-chip:active {
transform: scale(0.97);
}
/* Externe-Ahnen active state. */
.rmf-bar-chip.is-on {
background: var(--color-accent-soft);
border-color: color-mix(in srgb, var(--color-accent) 32%, transparent);
color: color-mix(in srgb, var(--color-accent) 78%, #2b2723);
}
.rmf-bar-chip-label {
overflow: hidden;
text-overflow: ellipsis;
}
.rmf-bar-chip-ico {
color: var(--color-accent);
font-weight: 800;
}
.rmf-bar-chip-chev {
font-size: 11px;
color: var(--color-text-faint);
}
/* Toggle icon circle on the Externe-Ahnen chip (+ → ✓). */
.rmf-chip-toggle-ico {
font-size: 14px;
line-height: 1;
width: 17px;
height: 17px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 800;
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
color: var(--color-accent);
transition: background 0.15s, color 0.15s;
}
.rmf-bar-chip.is-on .rmf-chip-toggle-ico {
background: var(--color-accent);
color: #fff;
}
/* Desktop: the sheet becomes a centered modal rather than a bottom drawer. */
@media (min-width: 768px) {
.rmf-scrim {
align-items: center;
}
.rmf-sheet {
border-radius: 18px;
max-width: 420px;
max-height: 70vh;
animation: rmfChipIn 0.2s var(--rmf-ease);
}
}

View File

@@ -1,91 +0,0 @@
import { describe, it, expect } from 'vitest'
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
describe('trait catalog', () => {
it('has 4 categories', () => {
expect(TRAIT_CATEGORIES).toHaveLength(4)
})
it('category names match spec', () => {
const names = TRAIT_CATEGORIES.map((c) => c.category)
expect(names).toEqual([
'Sozialverhalten',
'Eignung & Umgang',
'Hobbys & Eigenarten',
'Wesen & Temperament',
])
})
it('ALL_TRAITS flattens all categories', () => {
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
expect(ALL_TRAITS).toHaveLength(total)
})
it('all keys are unique', () => {
const keys = ALL_TRAITS.map((t) => t.key)
expect(new Set(keys).size).toBe(keys.length)
})
it('existing 15 keys are still present (stored on live animals)', () => {
const legacy = [
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
]
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
for (const key of legacy) {
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
}
})
it('new keys are present', () => {
const newKeys = [
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
'schredder', 'nestbauer', 'territorial',
]
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
for (const key of newKeys) {
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
}
})
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
expect(isWarnTrait('territorial')).toBe(true)
})
it('non-warn traits return false from isWarnTrait', () => {
expect(isWarnTrait('zutraulich')).toBe(false)
expect(isWarnTrait('dominant')).toBe(false)
expect(isWarnTrait('beobachtungstier')).toBe(false)
expect(isWarnTrait('schreckhaft')).toBe(false)
})
it('unknown key returns false from isWarnTrait', () => {
expect(isWarnTrait('not-a-real-key')).toBe(false)
})
it('traitLabel returns German label for known key', () => {
expect(traitLabel('zutraulich')).toBe('zutraulich')
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
expect(traitLabel('territorial')).toBe('territorial')
})
it('traitLabel returns the key itself for unknown key', () => {
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
})
it('traitLabels maps a list of keys to labels', () => {
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
'zutraulich',
'dominant (Leittier)',
])
})
it('traitLabels handles null and undefined gracefully', () => {
expect(traitLabels(null)).toEqual([])
expect(traitLabels(undefined)).toEqual([])
})
})

View File

@@ -1,23 +1,9 @@
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
import { de } from '../strings/de'
export interface TraitEntry {
key: string
label: string
warn?: true
}
export const ALL_TRAITS = de.character.traits
export interface TraitCategory {
category: string
traits: readonly TraitEntry[]
}
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
export function traitLabel(key: string): string {
return LABEL_BY_KEY.get(key) ?? key
@@ -27,7 +13,3 @@ export function traitLabel(key: string): string {
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
return (keys ?? []).map(traitLabel)
}
export function isWarnTrait(key: string): boolean {
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
}

View File

@@ -16,7 +16,6 @@ import {
fromJSON,
wildType,
extractGenotypeFlags,
displayGenotypeSafe,
} from '../genotype'
import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
@@ -47,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')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
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')
})
it('parses multi-char C-series alleles via maximal munch', () => {
@@ -73,8 +72,8 @@ describe('Genotype serialization', () => {
expect(g.P).toEqual(['P', 'p'])
})
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
it('wild type is AA CC DD EE GG PP spsp rere', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
})
})
@@ -103,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')
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
expect(only.probability.text).toBe('1')
expect(result.warnings).toHaveLength(0)
})
@@ -180,16 +179,16 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag')
})
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
expect(CATALOG_SIZE).toBe(70)
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
expect(CATALOG_SIZE).toBe(66)
})
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
// The first 17 frozen ColorVariety keys — their representative genotype
// MUST resolve back to their own name, never a later variety.
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
// chch+pp → REW engine check → 'REW' = entry.name.
// The first 18 are the frozen ColorVariety keys — their representative
// genotype MUST resolve back to their own name, never a later variety.
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
}
@@ -221,12 +220,11 @@ describe('Farbschlag catalog', () => {
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', 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[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
})
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -287,7 +285,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',
'AA CC DD EE Gg PP spsp rere',
)
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
})
@@ -299,16 +297,10 @@ 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 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
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp Slsl',
'AA CC DD EE GG PP spsp rere Slsl',
)
})
@@ -338,7 +330,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')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
})
it('extractGenotypeFlags reads deafness + tags', () => {
@@ -371,21 +363,19 @@ 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') // displayed as '-'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // 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',
'Aa C- DD EE GG Pp spsp rere',
)
})
})
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Kohlfuchsschimmel',
'Fuchsschimmel',
)
})
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
@@ -400,10 +390,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
expect(name).toBe('Schwarz')
expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
})
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
@@ -411,11 +401,9 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
})
it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
'Kohlfuchsschimmel',
'Fuchsschimmel',
)
})
})
@@ -433,8 +421,7 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
})
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -484,8 +471,8 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
@@ -494,12 +481,11 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
})
})
@@ -521,8 +507,7 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
})
@@ -540,73 +525,20 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
})
})
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
it('dd entries use Dilute prefix — no more X-dd names', () => {
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
})
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa")
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-)
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c)
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
// Counterproof: full C present → NOT REW (residual pigment)
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
})
it('Farbarten (categories) never appear as computed results', () => {
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
for (const cat of CATS) {
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
}
})
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
})
})
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
// ── Display symbols ────────────────────────────────────────────────────
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',
'AA CC DD e[f]e[f] GG PP spsp rere',
)
// 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',
'aa c[chm]c[h] DD EE GG PP spsp rere',
)
// 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',
'aa c[chm]c[chm] DD EE GG PP spsp rere',
)
})
@@ -616,24 +548,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')
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
})
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',
'aa CC DD Ee GG PP spsp rere',
)
})
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',
'aa CC DD Ee[f] GG PP spsp rere',
)
})
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
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'
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'
const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?'])
@@ -641,16 +573,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', () => {
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp'
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'
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', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp'
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd'])
@@ -673,7 +605,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')
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
})
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
@@ -685,50 +617,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')
})
// ── GENOTYPE-PARSE-CRASH / displayGenotypeSafe resilience ───────────────
it('handles blank c locus and c[-] resiliently without crashing', () => {
// Vandana's genotype
const rawVandana = 'Aa Cc Dd eef Gg P? spsp ??'
const g = fromDisplayString(rawVandana)
expect(g.C).toEqual(['C', '?'])
expect(toDisplayString(g)).toBe('Aa C- Dd ee[f] Gg P- spsp')
// cchmc allele
const g2 = fromDisplayString('Aa cchmc Dd ee Gg Pp spsp')
expect(g2.C).toEqual(['cchm', '?'])
expect(toDisplayString(g2)).toBe('Aa c[chm]- Dd ee Gg Pp spsp')
// c[-] and blank c standalone
const g3 = fromDisplayString('Aa c[-] Dd ee Gg Pp spsp')
expect(g3.C).toEqual(['C', 'C']) // default to wild-type since c[-] was skipped
expect(toDisplayString(g3)).toBe('Aa CC Dd ee Gg Pp spsp')
const g4 = fromDisplayString('Aa c Dd ee Gg Pp spsp')
expect(g4.C).toEqual(['C', 'C']) // default to wild-type since c was skipped
expect(toDisplayString(g4)).toBe('Aa CC Dd ee Gg Pp spsp')
})
it('displayGenotypeSafe formats parsed genotypes and handles fallback safely', () => {
// 1. Parseable with unknown alleles, replacing ? with -
expect(displayGenotypeSafe('Aa C? Dd')).toBe('Aa C- Dd EE GG PP spsp')
// 2. Parseable with trailing ?? (stripped completely)
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp ??')).toBe('Aa CC Dd EE GG Pp spsp')
expect(displayGenotypeSafe('Aa Cc Dd eef Gg P? spsp ??')).toBe('Aa C- Dd ee[f] Gg P- spsp')
expect(displayGenotypeSafe('Aa CC Dd EE GG Pp spsp Slsl ??')).toBe('Aa CC Dd EE GG Pp spsp Slsl')
// 3. Unparseable fallback: removes ?? / -- / [-], replaces remaining ? with -
expect(displayGenotypeSafe('Aa Cc XX ??')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX --')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc XX [-]')).toBe('Aa Cc XX')
expect(displayGenotypeSafe('Aa Cc? XX')).toBe('Aa Cc- XX')
// 4. Handles null/undefined/empty
expect(displayGenotypeSafe(null)).toBe('')
expect(displayGenotypeSafe(undefined)).toBe('')
expect(displayGenotypeSafe('')).toBe('')
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
})
})

View File

@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
// to the frozen-name rule; the ColorVariety seed drops it too.
{ name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
@@ -66,8 +66,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
{ name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
@@ -78,24 +78,16 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
// agouti+dilute+fox combinations not otherwise covered in the catalog.
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
@@ -112,8 +104,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f/3g: c^chm colourpoint varieties ──
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
@@ -155,12 +147,10 @@ export interface FarbschlagMatch {
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
* a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
* variety specifically.
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
* raw pair, which runs before this.)
*/
function locusToken(g: Genotype, locus: LocusKey): string {
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
@@ -172,9 +162,7 @@ function locusToken(g: Genotype, locus: LocusKey): string {
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
// enables catalog entries like Kohlfuchsschimmel to match het animals).
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
}
return dominantAllele(locus, x, y)
@@ -187,12 +175,10 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
}
/**
* E-locus family: used to scope the catalog search to E-aware entries.
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
* implies a non-dominant extension pair, or null for full-extension/unknown.
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
* They are ONLY used here as catalog-search filters; they must NEVER appear as
* computed farbschlag output (the farbschlagFor category guard blocks them).
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
* Returns null when E is dominant (full colour) or fully unknown.
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
@@ -219,10 +205,7 @@ function baseColourFor(g: Genotype): string | null {
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
return base?.name ?? null
return base?.name ?? family
}
/**
@@ -250,13 +233,7 @@ function colourpointName(g: Genotype): string | null {
}
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
if (!base) return null
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
const DILUTE = 'Dilute '
if (base.startsWith(DILUTE)) {
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
}
return `CP-${base}${bothCchm ? '' : '-Hell'}`
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
}
export function farbschlagFor(g: Genotype): FarbschlagMatch {
@@ -264,30 +241,8 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
// cchm/cchm + pp → REW (CP varieties with pink-eye)
// cchm/ch + pp → REW (het colourpoint + pink-eye)
// ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here)
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
const [c0, c1] = resolvedPair(g, 'C')
const [p0, p1] = resolvedPair(g, 'P')
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
const name = ['REW', ...modifiers].join(' ')
return { name, base: null, unknown: false }
}
const baseName = colourpointName(g) ?? baseColourFor(g)
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
// a computed Farbschlag output. If baseName is a category label, treat as
// Unbekannt instead of leaking an invalid name into the UI.
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
'Standard', 'Colourpoint', 'Dilute',
'Fuchs', 'Fuchsschimmel', 'Schimmel',
'Colourpoint Dilute',
])
if (!baseName || CATEGORY_NAMES.has(baseName)) {
if (!baseName) {
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
}
const name = [baseName, ...modifiers].join(' ')

View File

@@ -1,6 +1,6 @@
[
{
"name": "REW",
"name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White",
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
"sortOrder": 0,
@@ -105,14 +105,14 @@
"image": "rotfuchs.JPG"
},
{
"name": "Dilute Gold",
"name": "dd Gold",
"english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 15,
"image": "gold-dd.jpg"
},
{
"name": "Dilute Platin",
"name": "dd Platin",
"english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"sortOrder": 16,
@@ -185,242 +185,222 @@
"image": "platin-hell.jpg"
},
{
"name": "Dilute Agouti",
"name": "Agouti dd",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"sortOrder": 28,
"image": "agouti-dd.jpg"
},
{
"name": "Dilute Silberagouti",
"name": "Silberagouti dd",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"sortOrder": 29,
"image": "silberagouti-dd.jpg"
},
{
"name": "Dilute Kohlfuchs",
"name": "Kohlfuchs dd",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"sortOrder": 30,
"image": "kohlfuchs-dd.jpg"
},
{
"name": "Dilute Anthrazit",
"name": "Anthrazit dd",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"sortOrder": 31,
"image": "anthrazit-dd.jpg"
},
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
"sortOrder": 35
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"sortOrder": 36,
"sortOrder": 32,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
"sortOrder": 37,
"sortOrder": 33,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
"sortOrder": 38,
"sortOrder": 34,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"sortOrder": 39,
"sortOrder": 35,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
"sortOrder": 40,
"sortOrder": 36,
"image": "blaufuchsschimmel.jpg"
},
{
"name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 41,
"sortOrder": 37,
"image": "kohlfuchs-hell.jpg"
},
{
"name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 42,
"sortOrder": 38,
"image": "goldfuchs-hell.jpg"
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
"sortOrder": 43,
"sortOrder": 39,
"image": "goldfuchsschimmel.jpg"
},
{
"name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 44,
"sortOrder": 40,
"image": "gold-hell.jpg"
},
{
"name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 45,
"sortOrder": 41,
"image": "blaufuchs-hell.jpeg"
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
"sortOrder": 46,
"sortOrder": 42,
"image": "rotfuchsschimmel.jpg"
},
{
"name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 47,
"sortOrder": 43,
"image": "polarfuchs-hell.jpeg"
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
"sortOrder": 48,
"sortOrder": 44,
"image": "kohlfuchsschimmel-hell.jpg"
},
{
"name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 49,
"sortOrder": 45,
"image": "rotfuchs-hell.jpg"
},
{
"name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 50,
"sortOrder": 46,
"image": "kohlfuchs-hell-2.jpg"
},
{
"name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 51,
"sortOrder": 47,
"image": "algierfuchs-hell.JPG"
},
{
"name": "Dilute Topas",
"name": "Topas dd",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 52,
"sortOrder": 48,
"image": "topas-dd.jpg"
},
{
"name": "Dilute Blaufuchs",
"name": "Blaufuchs dd",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"sortOrder": 53,
"sortOrder": 49,
"image": "blaufuchs-dd.jpg"
},
{
"name": "Marder",
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
"sortOrder": 54,
"sortOrder": 50,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
"sortOrder": 55,
"sortOrder": 51,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
"sortOrder": 56,
"sortOrder": 52,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
"sortOrder": 57,
"sortOrder": 53,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"sortOrder": 58
"sortOrder": 54
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 59,
"sortOrder": 55,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"sortOrder": 60
"sortOrder": 56
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 61,
"sortOrder": 57,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"sortOrder": 62
"sortOrder": 58
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 63,
"sortOrder": 59,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"sortOrder": 64
"sortOrder": 60
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 65
"sortOrder": 61
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 66
"sortOrder": 62
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 67
"sortOrder": 63
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 68
"sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"sortOrder": 69
"sortOrder": 65
}
]

View File

@@ -1,426 +1,406 @@
[
{
"name": "REW",
"name": "Pink Eyed White (PEW)",
"english": "Pink Eyed White",
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp",
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
"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",
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 1,
"image": "hermelin.jpeg"
},
{
"name": "Himalaya",
"english": "Himalayan",
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp",
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
"sortOrder": 2,
"image": "himalaya.jpg"
},
{
"name": "Zobel",
"english": "Sable",
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp",
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 3,
"image": "zobel.jpeg"
},
{
"name": "Rotaugenschimmel",
"english": "Red-Eyed Roan",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 4,
"image": "rotaugen-schimmel.jpg"
},
{
"name": "Agouti",
"english": "Golden Agouti",
"canonicalGenotype": "AA CC DD EE GG PP spsp",
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
"sortOrder": 5,
"image": "agouti-mit-erklaerung-der-genloci.JPG"
},
{
"name": "Schwarz",
"english": "Black",
"canonicalGenotype": "aa CC DD EE GG PP spsp",
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
"sortOrder": 6,
"image": "schwarz.jpg"
},
{
"name": "Silberagouti",
"english": "Grey Agouti",
"canonicalGenotype": "AA CC DD EE gg PP spsp",
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
"sortOrder": 7,
"image": "silberagouti.jpg"
},
{
"name": "Anthrazit",
"english": "Slate",
"canonicalGenotype": "aa CC DD EE gg PP spsp",
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
"sortOrder": 8,
"image": "anthrazit.jpg"
},
{
"name": "Algierfuchs",
"english": "Dark-Eyed Honey",
"canonicalGenotype": "AA CC DD ee GG PP spsp",
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 9,
"image": "algierfuchs.jpg"
},
{
"name": "Blau",
"english": "Blue",
"canonicalGenotype": "aa CC dd EE GG PP spsp",
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
"sortOrder": 10,
"image": "blau-schwarz-dd.JPG"
},
{
"name": "Gold",
"english": "Argente Golden",
"canonicalGenotype": "AA CC DD EE GG pp spsp",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 11,
"image": "gold.jpg"
},
{
"name": "Platin",
"english": "Lilac",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 12,
"image": "platin.JPG"
},
{
"name": "Goldfuchs",
"english": "Yellow Fox",
"canonicalGenotype": "AA CC DD ee GG pp spsp",
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 13,
"image": "goldfuchs.jpg"
},
{
"name": "Rotfuchs",
"english": "Argente Nutmeg",
"canonicalGenotype": "aa CC DD ee GG pp spsp",
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 14,
"image": "rotfuchs.JPG"
},
{
"name": "Dilute Gold",
"name": "dd Gold",
"english": "dd Argente Golden",
"canonicalGenotype": "AA CC dd EE GG pp spsp",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 15,
"image": "gold-dd.jpg"
},
{
"name": "Dilute Platin",
"name": "dd Platin",
"english": "dd Lilac",
"canonicalGenotype": "aa CC dd EE GG pp spsp",
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
"sortOrder": 16,
"image": "platin-dd.jpg"
},
{
"name": "Altweiss (REW)",
"canonicalGenotype": "aa CC DD EE gg pp spsp",
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
"sortOrder": 17,
"image": "altweiss-rew.jpeg"
},
{
"name": "Apricot (Blassfuchs)",
"canonicalGenotype": "AA CC DD ee gg pp spsp",
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
"sortOrder": 18,
"image": "apricot-blassfuchs.jpg"
},
{
"name": "Blaufuchs",
"canonicalGenotype": "aa CC DD ee gg PP spsp",
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 19,
"image": "blaufuchs.jpg"
},
{
"name": "C-Separator",
"canonicalGenotype": "aa CC DD ee gg pp spsp",
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
"sortOrder": 20,
"image": "c-separator.jpg"
},
{
"name": "Elfenbein",
"canonicalGenotype": "AA CC DD EE gg pp spsp",
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
"sortOrder": 21,
"image": "elfenbein.jpg"
},
{
"name": "Kohlfuchs",
"canonicalGenotype": "aa CC DD ee GG PP spsp",
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 22,
"image": "kohlfuchs.jpg"
},
{
"name": "Polarfuchs",
"canonicalGenotype": "AA CC DD ee gg PP spsp",
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 23,
"image": "polarfuchs.jpg"
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 24,
"image": "saphir.jpg"
},
{
"name": "Orangeschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 25,
"image": "schimmel-orangeschimmel.jpg"
},
{
"name": "Topas",
"canonicalGenotype": "AA CC DD EE GG pp spsp",
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 26,
"image": "topas.jpg"
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
"sortOrder": 27,
"image": "platin-hell.jpg"
},
{
"name": "Dilute Agouti",
"canonicalGenotype": "AA CC dd EE GG PP spsp",
"name": "Agouti dd",
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
"sortOrder": 28,
"image": "agouti-dd.jpg"
},
{
"name": "Dilute Silberagouti",
"canonicalGenotype": "AA CC dd EE gg PP spsp",
"name": "Silberagouti dd",
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
"sortOrder": 29,
"image": "silberagouti-dd.jpg"
},
{
"name": "Dilute Kohlfuchs",
"canonicalGenotype": "aa CC dd ee GG PP spsp",
"name": "Kohlfuchs dd",
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
"sortOrder": 30,
"image": "kohlfuchs-dd.jpg"
},
{
"name": "Dilute Anthrazit",
"canonicalGenotype": "aa CC dd EE gg PP spsp",
"name": "Anthrazit dd",
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
"sortOrder": 31,
"image": "anthrazit-dd.jpg"
},
{
"name": "Dilute Algierfuchs",
"canonicalGenotype": "AA CC dd ee GG PP spsp",
"sortOrder": 32
},
{
"name": "Dilute Goldfuchs",
"canonicalGenotype": "AA CC dd ee GG pp spsp",
"sortOrder": 33
},
{
"name": "Dilute Rotfuchs",
"canonicalGenotype": "aa CC dd ee GG pp spsp",
"sortOrder": 34
},
{
"name": "Dilute Polarfuchs",
"canonicalGenotype": "AA CC dd ee gg PP spsp",
"sortOrder": 35
},
{
"name": "Silberschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
"sortOrder": 36,
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 32,
"image": "silberschimmel.jpg"
},
{
"name": "Polarfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
"sortOrder": 37,
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 33,
"image": "polarfuchsschimmel.jpg"
},
{
"name": "Algierfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
"sortOrder": 38,
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 34,
"image": "algierfuchsschimmel.jpg"
},
{
"name": "Kohlfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"sortOrder": 39,
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 35,
"image": "kohlfuchsschimmel.jpg"
},
{
"name": "Blaufuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
"sortOrder": 40,
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
"sortOrder": 36,
"image": "blaufuchsschimmel.jpg"
},
{
"name": "Kohlfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp",
"sortOrder": 41,
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 37,
"image": "kohlfuchs-hell.jpg"
},
{
"name": "Goldfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG pp spsp",
"sortOrder": 42,
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
"sortOrder": 38,
"image": "goldfuchs-hell.jpg"
},
{
"name": "Goldfuchsschimmel",
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
"sortOrder": 43,
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 39,
"image": "goldfuchsschimmel.jpg"
},
{
"name": "Gold-Hell",
"canonicalGenotype": "AA CC DD EE GG pp spsp",
"sortOrder": 44,
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
"sortOrder": 40,
"image": "gold-hell.jpg"
},
{
"name": "Blaufuchs, hell",
"canonicalGenotype": "aa CC DD ee gg PP spsp",
"sortOrder": 45,
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
"sortOrder": 41,
"image": "blaufuchs-hell.jpeg"
},
{
"name": "Rotfuchsschimmel",
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
"sortOrder": 46,
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
"sortOrder": 42,
"image": "rotfuchsschimmel.jpg"
},
{
"name": "Polarfuchs, hell",
"canonicalGenotype": "AA CC DD ee gg PP spsp",
"sortOrder": 47,
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
"sortOrder": 43,
"image": "polarfuchs-hell.jpeg"
},
{
"name": "Kohlfuchsschimmel, hell",
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
"sortOrder": 48,
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
"sortOrder": 44,
"image": "kohlfuchsschimmel-hell.jpg"
},
{
"name": "Rotfuchs, hell",
"canonicalGenotype": "aa CC DD ee GG pp spsp",
"sortOrder": 49,
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
"sortOrder": 45,
"image": "rotfuchs-hell.jpg"
},
{
"name": "Kohlfuchs-Hell",
"canonicalGenotype": "aa CC DD ee GG PP spsp",
"sortOrder": 50,
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
"sortOrder": 46,
"image": "kohlfuchs-hell-2.jpg"
},
{
"name": "Algierfuchs, hell",
"canonicalGenotype": "AA CC DD ee GG PP spsp",
"sortOrder": 51,
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
"sortOrder": 47,
"image": "algierfuchs-hell.JPG"
},
{
"name": "Dilute Topas",
"canonicalGenotype": "AA CC dd EE GG pp spsp",
"sortOrder": 52,
"name": "Topas dd",
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
"sortOrder": 48,
"image": "topas-dd.jpg"
},
{
"name": "Dilute Blaufuchs",
"canonicalGenotype": "aa CC dd ee gg pp spsp",
"sortOrder": 53,
"name": "Blaufuchs dd",
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
"sortOrder": 49,
"image": "blaufuchs-dd.jpg"
},
{
"name": "Marder",
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp",
"sortOrder": 54,
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 50,
"image": "marder.JPG"
},
{
"name": "Siam",
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp",
"sortOrder": 55,
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 51,
"image": "siam-marder-hell.JPG"
},
{
"name": "Zobel-Hell",
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp",
"sortOrder": 56,
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 52,
"image": "zobel-hell.jpg"
},
{
"name": "CP-Agouti",
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp",
"sortOrder": 57,
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
"sortOrder": 53,
"image": "agouti-cp.jpg"
},
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp",
"sortOrder": 58
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
"sortOrder": 54
},
{
"name": "CP-Silberagouti",
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp",
"sortOrder": 59,
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
"sortOrder": 55,
"image": "silberagouti-cp.JPG"
},
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp",
"sortOrder": 60
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
"sortOrder": 56
},
{
"name": "CP-Algierfuchs",
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp",
"sortOrder": 61,
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
"sortOrder": 57,
"image": "algierfuchs-cp.jpg"
},
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp",
"sortOrder": 62
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
"sortOrder": 58
},
{
"name": "CP-Polarfuchs",
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp",
"sortOrder": 63,
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
"sortOrder": 59,
"image": "polarfuchs-cp.jpg"
},
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp",
"sortOrder": 64
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
"sortOrder": 60
},
{
"name": "CP-Fuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp",
"sortOrder": 65
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
"sortOrder": 61
},
{
"name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp",
"sortOrder": 66
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
"sortOrder": 62
},
{
"name": "CP-Blaufuchs",
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp",
"sortOrder": 67
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
"sortOrder": 63
},
{
"name": "CP-Orangeschimmel",
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp",
"sortOrder": 68
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp",
"sortOrder": 69
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
"sortOrder": 65
}
]

View File

@@ -101,21 +101,18 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
}
/**
* 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].
* 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).
*/
export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.filter(
(locus) =>
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
)
.map((locus) => {
const [a, b] = displayPair(locus, g[locus])
@@ -192,11 +189,6 @@ function normalizeToken(tok: string): string | null {
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
// leaving the leading allele intact for splitToken).
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c\[-\]/g, '?')
t = t.replace(/(?<=[A-Za-z])c$/g, '?')
t = t.replace(/^c\[-\]$/g, '??')
t = t.replace(/^c\?$/g, '??')
t = t.replace(/^c$/g, '??')
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
t = t.replace(/-/g, '?')
@@ -262,25 +254,6 @@ export function fromDisplayString(input: string): Genotype {
return makeGenotype({ ...base, ...acc })
}
export function displayGenotypeSafe(raw: string | null | undefined): string {
if (!raw) return ''
try {
const parsed = fromDisplayString(raw)
return toDisplayString(parsed)
} catch {
// Fallback path:
return raw
.trim()
.split(/\s+/)
.filter((tok) => {
const norm = normalizeToken(tok)
return norm !== null && norm !== '?' && norm !== '??'
})
.map((tok) => tok.replace(/\?/g, '-'))
.join(' ')
}
}
export function hasUnknown(g: Genotype): boolean {
return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD)
}

View File

@@ -20,7 +20,6 @@ export {
toJSON,
fromJSON,
hasUnknown,
displayGenotypeSafe,
WILDCARD,
} from './genotype'
export type { Genotype, AllelePair } from './genotype'

View File

@@ -21,7 +21,7 @@ export interface AsyncState<T> {
reload: () => void
}
export function messageFor(err: unknown): string {
function messageFor(err: unknown): string {
if (err instanceof ApiError) return err.message
if (err instanceof Error && err.message) return err.message
return de.api.errors.unknown

View File

@@ -1,116 +0,0 @@
/**
* Server-paginated infinite list (no react-query).
*
* `loader(page)` fetches ONE page; pages are accumulated into `items`. The list
* never loads everything up front — `loadMore()` (wired to a scroll sentinel)
* advances exactly one page at a time until `hasMore` is false.
*
* Changing `resetKey` (e.g. the filter/sort/search signature) clears the list and
* reloads from page 1. `reload()` re-fetches page 1 on demand (e.g. retry).
*
* Design mirrors useApi (and satisfies the strict react-hooks rules):
* - the request signature lives in `req`; the query reset on `resetKey` change is
* an in-render state adjustment (not a setState-in-effect), so no cascade.
* - `loading` / `loadingMore` are DERIVED from resolved-vs-current request keys,
* never set synchronously inside the fetch effect.
* - the loader is read through a ref so the effect keys purely on the request id.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { messageFor } from './useApi'
import type { Paged } from '../api/types'
export interface InfiniteListState<T> {
items: T[]
total: number
/** Initial / reset load (page 1) in flight. */
loading: boolean
/** A subsequent page is being appended. */
loadingMore: boolean
error: string | null
hasMore: boolean
/** Advance one page; the scroll sentinel calls this. */
loadMore: () => void
/** Re-fetch from page 1 (retry). */
reload: () => void
}
interface Req {
key: string
page: number
nonce: number
}
interface Resolved<T> {
reqId: string
items: T[]
total: number
error: string | null
}
export function useInfiniteList<T>(
loader: (page: number) => Promise<Paged<T>>,
resetKey: string,
): InfiniteListState<T> {
const loaderRef = useRef(loader)
useEffect(() => {
loaderRef.current = loader
})
const [req, setReq] = useState<Req>({ key: resetKey, page: 1, nonce: 0 })
// Adjust state during render when the query signature changes — the recommended
// pattern for deriving state from changed inputs (no setState-in-effect cascade).
if (req.key !== resetKey) {
setReq({ key: resetKey, page: 1, nonce: 0 })
}
const queryId = `${req.nonce}:${req.key}`
const reqId = `${queryId}#${req.page}`
const [resolved, setResolved] = useState<Resolved<T>>({
reqId: '',
items: [],
total: 0,
error: null,
})
useEffect(() => {
let cancelled = false
const page = req.page
const id = `${req.nonce}:${req.key}#${page}`
loaderRef
.current(page)
.then((res) => {
if (cancelled) return
setResolved((prev) => ({
reqId: id,
// page 1 replaces (fresh query); later pages append.
items: page === 1 ? res.items : [...prev.items, ...res.items],
total: res.totalCount,
error: null,
}))
})
.catch((err) => {
if (!cancelled) setResolved((prev) => ({ ...prev, reqId: id, error: messageFor(err) }))
})
return () => {
cancelled = true
}
}, [req]) // req is a fresh object per change (key/page/nonce) → one fetch per change
// Resolved data only counts if it belongs to the current query signature.
const resolvedQueryId = resolved.reqId.slice(0, resolved.reqId.lastIndexOf('#'))
const sameQuery = resolved.reqId !== '' && resolvedQueryId === queryId
const inFlight = resolved.reqId !== reqId
const items = sameQuery ? resolved.items : []
const total = sameQuery ? resolved.total : 0
const loading = !sameQuery || (inFlight && req.page === 1)
const loadingMore = sameQuery && inFlight && req.page > 1
const error = inFlight ? null : sameQuery ? resolved.error : null
const hasMore = sameQuery && !inFlight && items.length < total
const loadMore = useCallback(() => setReq((r) => ({ ...r, page: r.page + 1 })), [])
const reload = useCallback(() => setReq((r) => ({ key: r.key, page: 1, nonce: r.nonce + 1 })), [])
return { items, total, loading, loadingMore, error, hasMore, loadMore, reload }
}

View File

@@ -2,20 +2,15 @@
Seitenleiste links ab 768px (Laptop). */
:root {
/* DESIGN (Rennmaus Filter): the app palette is the design's creme/brown/terracotta. */
--color-bg: #f6efe2;
--color-bg: #faf7f2;
--color-surface: #ffffff;
--color-border: #e7dcc8;
--color-text: #3d2e23;
--color-text-muted: #8c7f6e;
--color-text-faint: #b4a790;
--color-accent: #a85f2e;
--color-accent-soft: #f3e7da;
/* DESIGN (Rennmaus Filter): the single soft elevation used on cards, pills,
the search bar and buttons. */
--shadow: 0 1px 2px rgba(61, 46, 35, 0.06), 0 2px 8px rgba(61, 46, 35, 0.05);
--color-border: #e5ddd0;
--color-text: #3b3026;
--color-text-muted: #8a7d6b;
--color-accent: #b5651d;
--color-accent-soft: #f6e7d7;
font-family: 'Hanken Grotesk', system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
color: var(--color-text);
background-color: var(--color-bg);
@@ -46,41 +41,46 @@ a {
/* --- Grundgerüst (Smartphone) --- */
/* DESIGN: the top "Rennmaus-Manager" title bar is removed; on mobile only the
bottom action bar remains, on desktop the left sidebar.
The shell is a fixed viewport box and ONLY the main area scrolls — so the bottom
nav (mobile) and the sidebar (desktop) stay put, and the page header's
position: sticky pins reliably to the top of the scrolling main area. */
.app-shell {
display: grid;
height: 100dvh;
overflow: hidden;
grid-template-rows: 1fr auto;
min-height: 100dvh;
grid-template-rows: auto 1fr auto;
grid-template-areas:
'header'
'main'
'nav';
}
.app-main {
grid-area: main;
/* No top padding: the page's sticky header pins flush to the top with no gap
(pages add their own top spacing via .page). */
padding: 0 1rem 1.5rem;
max-width: 60rem;
width: 100%;
/* MAIN is the scroll container: min-height:0 lets the 1fr grid row shrink below
its content so it can scroll. overflow-anchor:none stops the browser from
re-adjusting scroll when the sticky title collapses (no scroll "fight"). */
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overflow-anchor: none;
.app-header {
grid-area: header;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
}
/* Top spacing for page content (replaces the removed .app-main top padding). The
Rennmäuse page opts out — its sticky header provides its own top padding. */
.page {
padding-top: 1rem;
.app-logo {
font-size: 1.5rem;
}
.app-title {
font-size: 1.15rem;
margin: 0;
}
.app-main {
grid-area: main;
padding: 1rem;
max-width: 60rem;
width: 100%;
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
the page body scroll horizontally. The .genotype-table-scroll wrapper
provides the per-table horizontal scroll. */
overflow-x: hidden;
}
/* Tab-Leiste unten */
@@ -92,8 +92,6 @@ a {
border-top: 1px solid var(--color-border);
position: sticky;
bottom: 0;
/* Above the sticky page header + cards so it is never overlapped. */
z-index: 30;
padding-bottom: env(safe-area-inset-bottom);
}
@@ -124,8 +122,10 @@ a {
@media (min-width: 768px) {
.app-shell {
grid-template-columns: 14rem 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'nav main';
grid-template-rows: auto 1fr;
grid-template-areas:
'header header'
'nav main';
}
.app-nav {
@@ -136,9 +136,6 @@ a {
border-right: 1px solid var(--color-border);
padding: 1rem 0.5rem;
position: static;
/* Sidebar stays fixed; scrolls internally only if the link list is taller
than the viewport. */
overflow-y: auto;
}
.nav-link {
@@ -159,7 +156,7 @@ a {
}
.app-main {
padding: 0 2rem 1.5rem;
padding: 1.5rem 2rem;
}
}
@@ -542,57 +539,28 @@ textarea {
margin: 0.5rem 0 1rem;
padding: 0;
display: grid;
/* GEN-4: wider min-width prevents long names (CP-Silberagouti-Hell) from overflowing */
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.5rem;
}
/* GEN-4 layout fix: image left, body centre (name + genotype stacked), prob right.
align-items: stretch so all three columns fill the card height consistently. */
.farbschlag-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 0.6rem;
background: var(--color-surface);
overflow: hidden; /* contain long genotype strings */
}
.farbschlag-card__img {
flex-shrink: 0;
}
/* Body takes the middle space and allows wrapping for long names */
.farbschlag-card__body {
flex: 1;
min-width: 0; /* allow flex child to shrink below content size */
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.farbschlag-card__name {
font-weight: 600;
overflow-wrap: break-word;
word-break: break-word;
}
/* Genotype code shown below the name — muted, small, wrappable */
.farbschlag-card__geno {
font-size: 0.7rem;
color: var(--color-text-muted, #666);
overflow-wrap: break-word;
word-break: break-all;
opacity: 0.8;
}
.farbschlag-card__prob {
flex-shrink: 0;
color: var(--color-accent);
white-space: nowrap;
text-align: right;
}
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile

Some files were not shown because too many files have changed in this diff Show More