Compare commits
125 Commits
feature/fo
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| d5c7b77e7e | |||
| bff25b4683 | |||
| aa1d22cec7 | |||
| f7e97ad7a2 | |||
| 7d552d33cd | |||
| 4d7333b714 | |||
| 62b07597f5 | |||
| 5ce35af813 | |||
| 23b6dad303 | |||
| e9eef5cce6 | |||
| 299c038449 | |||
| ecd7ad2658 | |||
| 6de56ab112 | |||
| ddc343a96b | |||
| a26bc4d75e | |||
| d4dfe6c3ac | |||
| 40f3ce250d | |||
| ffbbc0dfad | |||
| 05cb1af39e | |||
| 78f87f0d44 | |||
| 7f6855be62 | |||
| 29e9c4cd64 | |||
| 8eddb83c99 | |||
| 1a5f3218fb | |||
| 2d54cb901f | |||
| 03f8e4de90 | |||
| 70803efa1f | |||
| 25af10a736 | |||
| ade40eef5e | |||
| c1b215d06f | |||
| 7525970ffc | |||
| bfc90f8581 | |||
| c45bcc99f0 | |||
| c4a661b5b6 | |||
| 30b2fff775 | |||
| c0ecf2022d | |||
| 2198c33898 | |||
| 04971bf3bb | |||
| 08500757d7 | |||
| 05ef3d9567 | |||
| 7c12a65938 | |||
| 5361a292f1 | |||
| cb5acd2005 | |||
| bda5479b93 | |||
| d5c155953b | |||
| 36795bd974 | |||
| ae60b47ad3 | |||
| adb9e93cdb | |||
| 3aa9ac9811 | |||
| b72e0a9d9a | |||
| 12d85d9796 | |||
| 76bdde4649 | |||
| 025bca1840 | |||
| 58dc811574 | |||
| 367304940f | |||
| 7e1acdb5e4 | |||
| 5ee6a4edf6 | |||
| ea527c7ec6 | |||
| c80adcf060 | |||
| a42966ad8e | |||
| 886a6e3aae | |||
| 88e00b3718 | |||
| 3214855989 | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 | |||
| 4403574f3d | |||
| bcdea8011b | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| c452b69dd6 | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| f20a42a94b |
@@ -9,10 +9,10 @@
|
||||
# 2. npm test + npm run build (Frontend)
|
||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||
#
|
||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
||||
# Registry: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig)
|
||||
# Images:
|
||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-api:latest
|
||||
# git.rismer.de/gulum/gerbilmanager-frontend:latest
|
||||
|
||||
name: CI
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: truenas:13000
|
||||
REGISTRY: git.rismer.de
|
||||
REGISTRY_OWNER: gulum
|
||||
DOTNET_VERSION: "10.0.x"
|
||||
NODE_VERSION: "22"
|
||||
@@ -48,6 +48,26 @@ jobs:
|
||||
- name: Build
|
||||
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
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||
run: >
|
||||
dotnet ef migrations has-pending-model-changes
|
||||
--project GerbilManagerWebAPI
|
||||
--startup-project GerbilManagerWebAPI
|
||||
--no-build --configuration Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -131,6 +131,12 @@ $RECYCLE.BIN/
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
GerbilManagerWebAPI/photo-storage/
|
||||
|
||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||
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/
|
||||
|
||||
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
103
GerbilManager.Tests/CmsPublishTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>WEB-2: POST /api/publish — atomic swap, file layout, staging cleanup.</summary>
|
||||
public class CmsPublishTests
|
||||
{
|
||||
private static string TempRoot() =>
|
||||
Path.Combine(Path.GetTempPath(), "gm-publish-test-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_erstellt_live_Verzeichnis_mit_allen_Dateien()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
var files = new Dictionary<string, string>
|
||||
{
|
||||
["index.html"] = "<html>start</html>",
|
||||
["kontakt/index.html"] = "<html>kontakt</html>",
|
||||
["assets/site.css"] = "body {}",
|
||||
};
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(files, root);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "kontakt", "index.html")));
|
||||
Assert.True(File.Exists(Path.Combine(root, "live", "assets", "site.css")));
|
||||
Assert.Equal("<html>start</html>",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_atomarer_Swap_ueberschreibt_alte_live_Version()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-1" }, root);
|
||||
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "version-2" }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html"));
|
||||
Assert.Equal("version-2", content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_kein_staging_oder_old_Verzeichnis_nach_Swap()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = "x" }, root);
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_staging_new")));
|
||||
Assert.False(Directory.Exists(Path.Combine(root, "_old")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_mehrfach_ohne_Fehler()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = $"v{i}" }, root);
|
||||
}
|
||||
|
||||
Assert.Equal("v3",
|
||||
await File.ReadAllTextAsync(Path.Combine(root, "live", "index.html")));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publish_UTF8_Inhalt_korrekt_gespeichert()
|
||||
{
|
||||
var root = TempRoot();
|
||||
try
|
||||
{
|
||||
const string german = "<html>Züchter — Rennmäuse & mehr</html>";
|
||||
await CmsEndpoints.PublishToDirectoryAsync(
|
||||
new Dictionary<string, string> { ["index.html"] = german }, root);
|
||||
|
||||
var content = await File.ReadAllTextAsync(
|
||||
Path.Combine(root, "live", "index.html"),
|
||||
System.Text.Encoding.UTF8);
|
||||
Assert.Equal(german, content);
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
@@ -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 = "Active" });
|
||||
await _client.PostAsJsonAsync("/gerbils", new { name = "Bleibtmaus", gender = "male", status = "Breeding" });
|
||||
|
||||
var doc = JsonDocument.Parse(await _client.GetStringAsync("/api/site-snapshot"));
|
||||
var animals = doc.RootElement.GetProperty("pages").EnumerateArray()
|
||||
|
||||
@@ -60,7 +60,7 @@ namespace GerbilManager.Tests
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Krümel",
|
||||
Gender = Gender.female,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
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("Aktiv", row);
|
||||
Assert.Contains("Zucht", row);
|
||||
Assert.Contains("12.03.2025", row);
|
||||
Assert.Contains("Großbecken", row);
|
||||
Assert.Contains("Wurf K", row);
|
||||
|
||||
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
83
GerbilManager.Tests/GerbilProfilePhotoTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
58
GerbilManager.Tests/GerbilSearchTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
122
GerbilManager.Tests/GerbilStatusTests.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
343
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,343 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,31 @@ 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()
|
||||
{
|
||||
@@ -236,6 +261,54 @@ 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()
|
||||
{
|
||||
@@ -548,27 +621,32 @@ namespace GerbilManager.Tests
|
||||
[Fact]
|
||||
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||
{
|
||||
// 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
|
||||
// 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
|
||||
|
||||
// ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
|
||||
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
|
||||
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||
Assert.NotNull(cpFuchs);
|
||||
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||
|
||||
// New entries at IDs 62-66 exist with correct names
|
||||
// Existing CP-*-Hell entries (IDs 62-66) unchanged
|
||||
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);
|
||||
|
||||
// Total count is exactly 66
|
||||
Assert.Equal(66, db.ColorVarieties.Count());
|
||||
// 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());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -580,6 +658,74 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(new DateOnly(y, m, d), date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CR9_NameDOB_drift_falls_back_to_ExternalRef_no_throw()
|
||||
{
|
||||
// CR-9: if an already-imported animal's Name or DOB in animals.json no longer matches
|
||||
// what's stored in the DB (e.g. after a correctDob remap or manual UI rename), the
|
||||
// gidByNameDob lookup used to throw KeyNotFoundException. Now it falls back to the
|
||||
// stable ExternalRef without throwing.
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cr9-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[{"id":"drift","name":"Drift Tier","dob":"01.01.2021","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},
|
||||
"conflict":false}]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
// Run 1: load the animal normally
|
||||
await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync());
|
||||
|
||||
// Simulate drift: manually rename the animal in the DB (UI rename scenario)
|
||||
var g = await db.Gerbils.SingleAsync(x => x.ExternalRef == "drift");
|
||||
g.Name = "Umbenannt Tier";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Run 2: animals.json still has old name "Drift Tier" — must NOT throw
|
||||
var report2 = await new ImportService(db, dir, dir).RunAsync(execute: false);
|
||||
// Dry-run should complete without throwing; animal is found by ExternalRef fallback
|
||||
Assert.Equal(1, await db.Gerbils.CountAsync()); // no duplicate created
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CR11_ColorVariety_derived_from_genotype_when_no_explicit_farbschlag()
|
||||
{
|
||||
// CR-11: deep-band animals have empty Farbschlag but a full genotype. The loader
|
||||
// must derive ColorVarietyId from the catalog when the name-match yields nothing.
|
||||
// "Agouti" = aa CC DD EE GG PP spsp rere (first seed entry, ID 00000001).
|
||||
var dir = Path.Combine(Path.GetTempPath(), "cr11-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||
// Exact Agouti genotype, no explicit Farbschlag name
|
||||
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||
[{"id":"agouti-deep","name":"Opa Waldmann","dob":"01.01.2018","death":"","farbschlag":"",
|
||||
"genotype":{"mapped8locus":{"A":["a","a"],"C":["C","C"],"D":["D","D"],"E":["E","E"],"G":["G","G"],"P":["P","P"],"Sp":["sp","sp"],"Re":["re","re"]},
|
||||
"rawGenotype":"aa CC DD EE GG PP spsp rere","unmappedTokens":[]},
|
||||
"conflict":false}]
|
||||
""");
|
||||
using var db = NewDb();
|
||||
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||
|
||||
var tier = await db.Gerbils.SingleAsync(g => g.ExternalRef == "agouti-deep");
|
||||
// ColorVarietyId must be set even though no explicit Farbschlag name was given
|
||||
Assert.NotNull(tier.ColorVarietyId);
|
||||
// Should be the "Agouti" variety (id = 00000000-0000-0000-0000-000000000001)
|
||||
var variety = await db.ColorVarieties.FindAsync(tier.ColorVarietyId);
|
||||
Assert.Equal("Agouti", variety!.Name);
|
||||
// Report counter should reflect the genotype derivation
|
||||
Assert.True(report.Animals.FarbschlagDerivedFromGenotype > 0);
|
||||
}
|
||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||
}
|
||||
|
||||
// ---- fixtures ----
|
||||
private const string LittersJson = """
|
||||
[
|
||||
|
||||
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
88
GerbilManager.Tests/LitterMortalityTests.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
280
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
||||
{
|
||||
// Use name-derived unique externalRef so the DB-1 unique constraint doesn't fire
|
||||
// when multiple tests in the same fixture share the SQLite connection.
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||
{
|
||||
name,
|
||||
@@ -39,7 +41,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
notes = "Eine Notiz",
|
||||
externalRef = "ext-001",
|
||||
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
@@ -67,7 +69,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
@@ -89,13 +91,13 @@ 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("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Breeding", 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"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||
}
|
||||
@@ -122,7 +124,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
||||
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||
}
|
||||
@@ -136,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("Active", GetStr(json, "status"));
|
||||
Assert.Equal("Breeding", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
|
||||
@@ -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 = "Active")
|
||||
private async Task Create(string name, string? originBreeder = null, string status = "Breeding")
|
||||
{
|
||||
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: "Active");
|
||||
await Create("KombiAktiv", status: "Breeding");
|
||||
var hits = await Names("status==ForSale,nameSearch=*kombi");
|
||||
Assert.Contains("KombiVerkauf", hits);
|
||||
Assert.DoesNotContain("KombiAktiv", hits);
|
||||
|
||||
@@ -49,6 +49,13 @@ 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).
|
||||
@@ -58,6 +65,14 @@ 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>(
|
||||
@@ -81,6 +96,12 @@ public class ApplicationContext : DbContext
|
||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(g => g.ColorVariety).WithMany()
|
||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// DB-1: ExternalRef is the import idempotency key — enforce uniqueness at the DB level.
|
||||
// Filtered (nulls allowed: manually-entered animals have no ExternalRef).
|
||||
e.HasIndex(g => g.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Litter>(e =>
|
||||
@@ -89,6 +110,12 @@ 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 =>
|
||||
@@ -170,6 +197,15 @@ 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);
|
||||
}
|
||||
|
||||
@@ -246,79 +282,97 @@ public class ApplicationContext : DbContext
|
||||
/// </summary>
|
||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||
{
|
||||
// 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 =
|
||||
// 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 =
|
||||
{
|
||||
("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"),
|
||||
// --- 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),
|
||||
};
|
||||
|
||||
var rows = new ColorVariety[catalog.Length];
|
||||
@@ -326,11 +380,11 @@ public class ApplicationContext : DbContext
|
||||
{
|
||||
rows[i] = new ColorVariety
|
||||
{
|
||||
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
|
||||
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
|
||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||
Name = catalog[i].Name,
|
||||
CanonicalGenotype = catalog[i].Genotype,
|
||||
SortOrder = i,
|
||||
SortOrder = catalog[i].SortOrder,
|
||||
};
|
||||
}
|
||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||
|
||||
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
16
GerbilManagerWebAPI/Cms/PublicSiteOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace GerbilManagerWebAPI.Cms
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-2: path where POST /api/publish writes the rendered static site.
|
||||
/// Env var: PublicSite__RootPath (empty = publish disabled, returns 503).
|
||||
/// In production this volume is shared with the publicsite-nginx container.
|
||||
/// </summary>
|
||||
public sealed class PublicSiteOptions
|
||||
{
|
||||
public const string SectionName = "PublicSite";
|
||||
|
||||
public string? RootPath { get; set; }
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(RootPath);
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,15 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
List<string> CharacterTraits,
|
||||
string? CharacterNote,
|
||||
bool? IsDeaf,
|
||||
bool IsResident);
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
@@ -85,6 +87,7 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Name,
|
||||
DateOnly Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Cms;
|
||||
@@ -5,6 +6,7 @@ using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
@@ -32,6 +34,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(files.Select(kv => new { path = kv.Key, size = kv.Value.Length }).ToList());
|
||||
});
|
||||
|
||||
// ---- 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
|
||||
@@ -169,6 +186,36 @@ 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"
|
||||
|
||||
@@ -2,6 +2,7 @@ using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Contracts;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -104,11 +105,12 @@ 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;
|
||||
g.Status = GerbilStatus.GivenAway;
|
||||
GerbilStatusService.Apply(g, today);
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -16,8 +18,21 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
// GET /gerbils (Gridify: filter/order/page; e.g. status==Active, litterId==…, orderBy=name)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.Gerbils.AsNoTracking()
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
{
|
||||
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));
|
||||
});
|
||||
|
||||
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
|
||||
group.MapGet("/breeders", async (ApplicationContext db) =>
|
||||
@@ -30,7 +45,14 @@ 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);
|
||||
return g is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(g));
|
||||
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));
|
||||
});
|
||||
|
||||
// POST /gerbils
|
||||
@@ -86,7 +108,8 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(i.Name)) g.Name = i.Name!;
|
||||
g.Gender = i.Gender ?? (isCreate ? Gender.unknown : g.Gender);
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||
// Status is applied as a user preference and then overridden by GerbilStatusService.
|
||||
g.Status = i.Status ?? (isCreate ? GerbilStatus.Breeding : g.Status);
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
@@ -105,12 +128,13 @@ 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) => new(
|
||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => 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);
|
||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl);
|
||||
}
|
||||
}
|
||||
|
||||
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return l is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>>> (LitterInput input, ApplicationContext db) =>
|
||||
group.MapPost("/", async Task<Results<Created<LitterDto>, BadRequest<ParentGenderError>, BadRequest<string>>> (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);
|
||||
@@ -35,12 +37,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Created($"/litters/{l.Id}", ToDto(l));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>>> (Guid id, LitterInput input, ApplicationContext db) =>
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (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);
|
||||
@@ -76,14 +80,25 @@ 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.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
|
||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,9 +48,11 @@ namespace GerbilManagerWebAPI.Export
|
||||
|
||||
private static readonly Dictionary<GerbilStatus, string> StatusDe = new()
|
||||
{
|
||||
[GerbilStatus.Active] = "Aktiv",
|
||||
[GerbilStatus.Breeding] = "Zucht",
|
||||
[GerbilStatus.Deceased] = "Verstorben",
|
||||
[GerbilStatus.GivenAway] = "Abgegeben",
|
||||
[GerbilStatus.ForSale] = "Abzugeben",
|
||||
[GerbilStatus.Pet] = "Liebhaber",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<HealthRecordType, string> HealthTypeDe = new()
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
425
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
425
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,425 @@
|
||||
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);
|
||||
}
|
||||
@@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
int AlreadyImported,
|
||||
QuarantineSummary Quarantined,
|
||||
int ParentLinksFromChart = 0,
|
||||
int ConflictsResolvedByDecision = 0);
|
||||
int ConflictsResolvedByDecision = 0,
|
||||
int FarbschlagDerivedFromGenotype = 0);
|
||||
|
||||
public sealed record QuarantineSummary(
|
||||
int Conflicts,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
@@ -71,17 +72,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
else if (conf == "niedrig") dateOnly++;
|
||||
}
|
||||
|
||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
||||
// existing rows (idempotency).
|
||||
var existingGerbilExtRefs = await _db.Gerbils
|
||||
.Where(g => g.ExternalRef != null)
|
||||
.Select(g => g.ExternalRef!).ToListAsync();
|
||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||
|
||||
var existingLitterKeys = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterKeys
|
||||
// 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
|
||||
.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();
|
||||
@@ -94,10 +99,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 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.
|
||||
// so skip them 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)
|
||||
@@ -107,7 +112,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
// 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; }
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
@@ -122,6 +130,7 @@ 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)
|
||||
@@ -145,25 +154,63 @@ namespace GerbilManagerWebAPI.Import
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
||||
// CR-9: ExternalRef → Gerbil.Id fallback for name/DOB drift on re-import
|
||||
var existingGidByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.ToDictionary(g => g.ExternalRef!, g => g.Id);
|
||||
|
||||
// CR-11: load CanonicalGenotype for genotype-derived Farbschlag matching
|
||||
var varietiesWithGeno = await _db.ColorVarieties
|
||||
.Select(v => new { v.Id, v.Name, v.CanonicalGenotype }).ToListAsync();
|
||||
|
||||
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||
var plan = new List<AnimalPlan>();
|
||||
int fbDerivedFromGenotype = 0;
|
||||
foreach (var a in loadable)
|
||||
{
|
||||
bool exists = existingGerbilSet.Contains(a.Id);
|
||||
var gid = exists ? gidByNameDob[NameDobKey(a.Name, ParseDate(a.Dob))] : Guid.NewGuid();
|
||||
// CR-9: use TryGetValue; fall back to ExternalRef lookup for name/DOB drift
|
||||
// (e.g. correctDob remap or manual rename). Prevents throwing KeyNotFoundException.
|
||||
Guid gid;
|
||||
if (exists)
|
||||
{
|
||||
if (!gidByNameDob.TryGetValue(NameDobKey(a.Name, ParseDate(a.Dob)), out gid))
|
||||
{
|
||||
if (existingGidByExtRef.TryGetValue(a.Id, out gid))
|
||||
notes.Add($"Hinweis: '{a.Name}' (*{a.Dob}) per ExternalRef gefunden trotz Name/DOB-Drift (correctDob oder UI-Umbenennung).");
|
||||
else
|
||||
{
|
||||
notes.Add($"Warnung: ExternalRef '{a.Id}' in DB vorhanden aber nicht auflösbar — Tier übersprungen.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
else gid = Guid.NewGuid();
|
||||
|
||||
Guid? wurfLitterId = null;
|
||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||
wurfLitterId = lid;
|
||||
|
||||
// CR-11: Farbschlag from explicit name-match first; fall back to genotype derivation
|
||||
// (fill-NULL-only — never overwrites an explicit name-match or manual assignment).
|
||||
Guid? colorVarietyId = null;
|
||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
foreach (var fb in fbCandidates)
|
||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||
{ colorVarietyId = vid; break; }
|
||||
if (colorVarietyId is null && a.Genotype.Mapped8locus.Count >= 8)
|
||||
{
|
||||
// CR-11: only derive from a fully-specified genotype (all 8 loci known,
|
||||
// no "??" wildcards). Partial genotypes (single-locus or sparse records)
|
||||
// would match any catalog entry via wildcards and produce false positives.
|
||||
var composed = ComposeGenotype(a.Genotype);
|
||||
if (!composed.Contains("??"))
|
||||
foreach (var v in varietiesWithGeno)
|
||||
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||
&& GenotypePotentiallyMatches(composed, v.CanonicalGenotype))
|
||||
{ colorVarietyId = v.Id; fbDerivedFromGenotype++; break; }
|
||||
}
|
||||
|
||||
var gender = InferGender(a, sireNames, damNames);
|
||||
var norm = Normalize(StripZucht(a.Name));
|
||||
@@ -243,10 +290,15 @@ namespace GerbilManagerWebAPI.Import
|
||||
litterParents[lid] = (f, m);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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".
|
||||
if (execute)
|
||||
{
|
||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||
@@ -270,13 +322,17 @@ 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 = sl.Father,
|
||||
MotherId = sl.Mother,
|
||||
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||
});
|
||||
}
|
||||
@@ -335,12 +391,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
if (execute)
|
||||
{
|
||||
_db.Gerbils.Add(new Gerbil
|
||||
var importedGerbil = new Gerbil
|
||||
{
|
||||
Id = p.Gid,
|
||||
Name = p.A.Name,
|
||||
Gender = p.Gender,
|
||||
Status = GerbilStatus.Active,
|
||||
Status = GerbilStatus.Breeding,
|
||||
DateOfBirth = ParseDate(p.A.Dob),
|
||||
DateOfDeath = ParseDate(p.A.Death),
|
||||
LitterId = litterId,
|
||||
@@ -364,7 +420,9 @@ namespace GerbilManagerWebAPI.Import
|
||||
p.A.SourceFiles,
|
||||
FarbschlagRaw = p.A.Farbschlag,
|
||||
}),
|
||||
});
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
_db.Gerbils.Add(importedGerbil);
|
||||
}
|
||||
|
||||
// photos
|
||||
@@ -391,6 +449,20 @@ 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)
|
||||
{
|
||||
@@ -469,6 +541,33 @@ namespace GerbilManagerWebAPI.Import
|
||||
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
||||
|
||||
// CR-11: FARBSCHLAG FROM GENOTYPE post-sweep (fill-NULL-only, safe): existing DB animals
|
||||
// with null ColorVarietyId whose stored Genotype matches a catalog entry get filled.
|
||||
// Mirrors the plan-loop derivation; never overwrites a manually-set or name-matched value.
|
||||
{
|
||||
var noColor = await _db.Gerbils
|
||||
.Where(g => g.ColorVarietyId == null && g.Genotype != null)
|
||||
.Select(g => new { g.Id, g.Genotype })
|
||||
.ToListAsync();
|
||||
foreach (var g in noColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(g.Genotype) || g.Genotype!.Contains("??")) continue;
|
||||
Guid? derivedVid = null;
|
||||
foreach (var v in varietiesWithGeno)
|
||||
if (!string.IsNullOrWhiteSpace(v.CanonicalGenotype)
|
||||
&& GenotypePotentiallyMatches(g.Genotype, v.CanonicalGenotype))
|
||||
{ derivedVid = v.Id; break; }
|
||||
if (derivedVid is null) continue;
|
||||
fbDerivedFromGenotype++;
|
||||
if (execute)
|
||||
{
|
||||
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||
if (row is not null && row.ColorVarietyId is null) row.ColorVarietyId = derivedVid;
|
||||
}
|
||||
}
|
||||
if (execute && fbDerivedFromGenotype > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// HERKUNFT BACKFILL (fill-NULL-only, safe): sweep all resident animals whose
|
||||
// OriginBreeder is null and fill it with a derived value or 'Zucht der Kleinen Chaoten'.
|
||||
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||
@@ -524,6 +623,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||
if (conflictsResolvedByDecision > 0)
|
||||
notes.Add($"Konfliktauflösungen: {conflictsResolvedByDecision} Tier(e) anhand von conflict-decisions.json un-quarantänet (Genotyp/Farbschlag der Züchterin ist maßgeblich).");
|
||||
if (fbDerivedFromGenotype > 0)
|
||||
notes.Add($"Farbschlag aus Genotyp: {fbDerivedFromGenotype} Tier(e) ohne expliziten Farbschlag-Namen wurden über den Katalog-Genotyp-Abgleich zugeordnet (band-aware Deep-Band-Tiere).");
|
||||
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||
|
||||
return new ImportReport(
|
||||
@@ -532,7 +633,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
Animals: new AnimalSummary(
|
||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||
parentLinksAdded, conflictsResolvedByDecision),
|
||||
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||
Samples: samples,
|
||||
Notes: notes,
|
||||
@@ -570,6 +671,23 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
||||
|
||||
/// <summary>CR-11: check if a composed animal genotype is compatible with a catalog canonical
|
||||
/// genotype. Both are space-separated 8-locus tokens (e.g. "aa CC DD ee GG PP spsp rere").
|
||||
/// "??" in either position is a wildcard. The first 8 tokens are compared; any trailing
|
||||
/// Sls token is ignored (it is outside the base 8-locus contract).</summary>
|
||||
private static bool GenotypePotentiallyMatches(string animalGeno, string catalogGeno)
|
||||
{
|
||||
var a = animalGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var c = catalogGeno.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (a.Length < 8 || c.Length < 8) return false;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
if (a[i] == "??" || c[i] == "??") continue;
|
||||
if (!string.Equals(a[i], c[i], StringComparison.OrdinalIgnoreCase)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Gender InferGender(SourceAnimal a, HashSet<string> sires, HashSet<string> dams)
|
||||
{
|
||||
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||
|
||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UniqueExternalRef : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Gerbils_ExternalRef",
|
||||
table: "Gerbils",
|
||||
column: "ExternalRef",
|
||||
unique: true,
|
||||
filter: "\"ExternalRef\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Gerbils_ExternalRef",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
33
GerbilManagerWebAPI/Migrations/20260606231220_RenameREW.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RenameREW : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "REW");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
column: "Name",
|
||||
value: "Pink Eyed White (PEW)");
|
||||
}
|
||||
}
|
||||
}
|
||||
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
40
GerbilManagerWebAPI/Migrations/20260607012436_StatusModel.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
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';");
|
||||
}
|
||||
}
|
||||
}
|
||||
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
1433
GerbilManagerWebAPI/Migrations/20260607013052_AddLitterMortality.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
@@ -215,7 +216,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
|
||||
Name = "Pink Eyed White (PEW)",
|
||||
Name = "REW",
|
||||
SortOrder = 0
|
||||
},
|
||||
new
|
||||
@@ -320,14 +321,14 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "dd Gold",
|
||||
Name = "Dilute Gold",
|
||||
SortOrder = 15
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||
Name = "dd Platin",
|
||||
Name = "Dilute Platin",
|
||||
SortOrder = 16
|
||||
},
|
||||
new
|
||||
@@ -411,28 +412,28 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||
Name = "Agouti dd",
|
||||
Name = "Dilute Agouti",
|
||||
SortOrder = 28
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||
Name = "Silberagouti dd",
|
||||
Name = "Dilute Silberagouti",
|
||||
SortOrder = 29
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs dd",
|
||||
Name = "Dilute Kohlfuchs",
|
||||
SortOrder = 30
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||
Name = "Anthrazit dd",
|
||||
Name = "Dilute Anthrazit",
|
||||
SortOrder = 31
|
||||
},
|
||||
new
|
||||
@@ -440,224 +441,224 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Silberschimmel",
|
||||
SortOrder = 32
|
||||
SortOrder = 36
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 33
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 34
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 35
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 36
|
||||
SortOrder = 40
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs, hell",
|
||||
SortOrder = 37
|
||||
SortOrder = 41
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||
Name = "Goldfuchs, hell",
|
||||
SortOrder = 38
|
||||
SortOrder = 42
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 39
|
||||
SortOrder = 43
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||
Name = "Gold-Hell",
|
||||
SortOrder = 40
|
||||
SortOrder = 44
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||
Name = "Blaufuchs, hell",
|
||||
SortOrder = 41
|
||||
SortOrder = 45
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 42
|
||||
SortOrder = 46
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||
Name = "Polarfuchs, hell",
|
||||
SortOrder = 43
|
||||
SortOrder = 47
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 44
|
||||
SortOrder = 48
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||
Name = "Rotfuchs, hell",
|
||||
SortOrder = 45
|
||||
SortOrder = 49
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||
Name = "Kohlfuchs-Hell",
|
||||
SortOrder = 46
|
||||
SortOrder = 50
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||
Name = "Algierfuchs, hell",
|
||||
SortOrder = 47
|
||||
SortOrder = 51
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||
Name = "Topas dd",
|
||||
SortOrder = 48
|
||||
Name = "Dilute Topas",
|
||||
SortOrder = 52
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||
Name = "Blaufuchs dd",
|
||||
SortOrder = 49
|
||||
Name = "Dilute Blaufuchs",
|
||||
SortOrder = 53
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "Marder",
|
||||
SortOrder = 50
|
||||
SortOrder = 54
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
||||
Name = "Siam",
|
||||
SortOrder = 51
|
||||
SortOrder = 55
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
||||
Name = "Zobel-Hell",
|
||||
SortOrder = 52
|
||||
SortOrder = 56
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||
Name = "CP-Agouti",
|
||||
SortOrder = 53
|
||||
SortOrder = 57
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||
Name = "CP-Silberagouti",
|
||||
SortOrder = 54
|
||||
SortOrder = 59
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||
Name = "CP-Algierfuchs",
|
||||
SortOrder = 55
|
||||
SortOrder = 61
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||
Name = "CP-Polarfuchs",
|
||||
SortOrder = 56
|
||||
SortOrder = 63
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
||||
Name = "CP-Fuchs",
|
||||
SortOrder = 57
|
||||
SortOrder = 65
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
||||
Name = "CP-Fuchs-Hell",
|
||||
SortOrder = 58
|
||||
SortOrder = 66
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
||||
Name = "CP-Blaufuchs",
|
||||
SortOrder = 59
|
||||
SortOrder = 67
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||
Name = "CP-Orangeschimmel",
|
||||
SortOrder = 60
|
||||
SortOrder = 68
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
||||
Name = "CP-Agouti-Hell",
|
||||
SortOrder = 61
|
||||
SortOrder = 58
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
||||
Name = "CP-Silberagouti-Hell",
|
||||
SortOrder = 62
|
||||
SortOrder = 60
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
||||
Name = "CP-Algierfuchs-Hell",
|
||||
SortOrder = 63
|
||||
SortOrder = 62
|
||||
},
|
||||
new
|
||||
{
|
||||
@@ -671,7 +672,35 @@ 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 = 65
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -689,7 +718,8 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
@@ -777,16 +807,19 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("NameSearch")
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("OriginBreeder")
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
@@ -807,6 +840,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.HasIndex("EnclosureId");
|
||||
|
||||
b.HasIndex("ExternalRef")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("LitterId");
|
||||
|
||||
b.HasIndex("OriginContactId");
|
||||
@@ -887,12 +924,21 @@ 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");
|
||||
|
||||
@@ -911,6 +957,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalRef")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("FatherId");
|
||||
|
||||
b.HasIndex("MotherId");
|
||||
|
||||
@@ -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.Active;
|
||||
public GerbilStatus Status { get; set; } = GerbilStatus.Breeding;
|
||||
|
||||
// Birth litter (the litter this gerbil was born in).
|
||||
public Guid? LitterId { get; set; }
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>Lifecycle status of a gerbil. Serialised as the string name on the wire.</summary>
|
||||
/// <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>
|
||||
public enum GerbilStatus
|
||||
{
|
||||
Active = 0,
|
||||
/// <summary>Aktiv in der Zucht (vormals "Active").</summary>
|
||||
Breeding = 0,
|
||||
Deceased = 1,
|
||||
GivenAway = 2,
|
||||
/// <summary>Alive, at home, offered for Abgabe ("Abzugeben"). Drives FEAT-12 sale listing.</summary>
|
||||
ForSale = 3
|
||||
ForSale = 3,
|
||||
/// <summary>Lebendes Heimtier, nicht in Zucht ("Liebhaber").</summary>
|
||||
Pet = 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ 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; }
|
||||
@@ -25,5 +29,14 @@ 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; }
|
||||
}
|
||||
}
|
||||
|
||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
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);
|
||||
}
|
||||
162
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
162
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
@@ -44,9 +47,25 @@ 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.
|
||||
builder.Services.AddDataProtection();
|
||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||
// In prod the path is mounted to a persistent volume (compose DataProtection__KeyRingPath).
|
||||
// In dev (Aspire) keys live in the content root — ephemeral, which is fine there.
|
||||
{
|
||||
var keyRingPath = builder.Configuration["DataProtection:KeyRingPath"]
|
||||
?? Path.Combine(builder.Environment.ContentRootPath, ".data-protection-keys");
|
||||
Directory.CreateDirectory(keyRingPath);
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
|
||||
.SetApplicationName("GerbilManager");
|
||||
}
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.MailSettingsService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||
@@ -67,7 +86,23 @@ app.MapScalarApiReference();
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.Migrate();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
app.UseCors(LanCorsPolicy);
|
||||
@@ -84,11 +119,13 @@ app.MapInbreedingEndpoints();
|
||||
app.MapPhotoEndpoints();
|
||||
app.MapSaleAdEndpoints();
|
||||
app.MapImportEndpoints();
|
||||
app.MapImportDocxEndpoints();
|
||||
app.MapContractEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
53
GerbilManagerWebAPI/Services/GerbilStatusService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
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
168
HUMANQUESTION.md
@@ -1,168 +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
|
||||
> („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 D1–D5-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.*
|
||||
105
HUMANQUESTION.md.migrated
Normal file
105
HUMANQUESTION.md.migrated
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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).
|
||||
- **C1–C9 — 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).
|
||||
- **D1–D6 — 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.*
|
||||
@@ -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,17 +7,28 @@ POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
|
||||
# Gitea Container Registry (Standard: truenas:13000/gulum)
|
||||
REGISTRY=truenas:13000/gulum
|
||||
# Container Registry (git.rismer.de/gulum)
|
||||
REGISTRY=git.rismer.de/gulum
|
||||
TAG=latest
|
||||
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...)
|
||||
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Backup-Rotation: Anzahl Tage (Standard: 7)
|
||||
BACKUP_KEEP_DAYS=7
|
||||
|
||||
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
|
||||
ANTHROPIC_API_KEY=
|
||||
# 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
|
||||
|
||||
@@ -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:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: GerbilManagerWebAPI/Dockerfile
|
||||
@@ -40,10 +40,20 @@ services:
|
||||
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
||||
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
||||
Photos__RootPath: /data/photos
|
||||
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
|
||||
DataProtection__KeyRingPath: /data/keys
|
||||
# AR-4: KI-Funktionen (Verkaufstext + Posteingang-Entwurf, Sektion AI; beliebiger OpenAI-kompatibler Anbieter)
|
||||
# Anbieter-Optionen und Schlüssel-Beispiele: docs/ai-provider.md
|
||||
# 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
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
- keys:/data/keys
|
||||
- publicsite:/data/publicsite
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -56,7 +66,7 @@ services:
|
||||
|
||||
# --- nginx Frontend (React SPA + API-Proxy) ---
|
||||
frontend:
|
||||
image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}"
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
@@ -67,6 +77,21 @@ 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
|
||||
@@ -88,22 +113,37 @@ services:
|
||||
|
||||
volumes:
|
||||
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
|
||||
# TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
|
||||
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name>
|
||||
pgdata:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
|
||||
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}"
|
||||
photos:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}"
|
||||
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||
keys:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"
|
||||
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}"
|
||||
# WEB-2: gemeinsames Volume fuer api (rw) und publicsite-nginx (ro).
|
||||
publicsite:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PUBLICSITE_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite}"
|
||||
|
||||
29
deploy/truenas/nginx/publicsite.conf
Normal file
29
deploy/truenas/nginx/publicsite.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
# GerbilManager — publicsite nginx (WEB-2)
|
||||
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html/live;
|
||||
index index.html;
|
||||
charset utf-8;
|
||||
|
||||
# Alle Seiten: no-cache (Aenderungen sofort sichtbar nach Veroeffentlichen)
|
||||
location / {
|
||||
try_files $uri $uri/index.html =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
}
|
||||
|
||||
# CSS/Bilder: kurze TTL (1 Tag)
|
||||
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||
try_files $uri =404;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
# Kein Zugriff auf Staging-Verzeichnisse
|
||||
location ~ ^/_(staging_new|old)/ {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
37
deploy/truenas/vhost-snippet.conf
Normal file
37
deploy/truenas/vhost-snippet.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||
#
|
||||
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||
server {
|
||||
listen 80;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name <DOMAIN>;
|
||||
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:8081;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
@@ -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-2.0-flash` | Free Tier |
|
||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | 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-2.0-flash'
|
||||
$env:AI__Model = 'gemini-flash-latest'
|
||||
```
|
||||
|
||||
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-2.0-flash
|
||||
AI__Model: gemini-flash-latest
|
||||
```
|
||||
|
||||
327
docs/ops.md
327
docs/ops.md
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS)
|
||||
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye)
|
||||
|
||||
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
|
||||
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
|
||||
@@ -9,13 +9,12 @@
|
||||
|
||||
1. [Übersicht & Architektur](#1-übersicht--architektur)
|
||||
2. [Voraussetzungen](#2-voraussetzungen)
|
||||
3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
|
||||
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye)
|
||||
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
|
||||
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
|
||||
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
|
||||
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
|
||||
8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
|
||||
9. [Fehlerbehebung](#9-fehlerbehebung)
|
||||
8. [Fehlerbehebung](#8-fehlerbehebung)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@
|
||||
|
||||
```
|
||||
Browser / Handy
|
||||
| HTTP :80
|
||||
| HTTP :80 (oder PORT aus .env, z.B. 8080)
|
||||
v
|
||||
┌──────────────────┐
|
||||
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
|
||||
@@ -40,9 +39,10 @@ Browser / Handy
|
||||
│ db (Postgres 17)│ │ backup (Sidecar) │
|
||||
└──────────────────┘ │ pg_dump + tar + cron │
|
||||
│ └──────────────────────┘
|
||||
└─ pgdata-Volume (NAS-Dataset)
|
||||
photos-Volume (NAS-Dataset)
|
||||
backups-Volume (NAS-Dataset)
|
||||
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
```
|
||||
|
||||
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
|
||||
@@ -54,68 +54,107 @@ Alles andere läuft intern im Docker-Netz.
|
||||
|
||||
| Was | Details |
|
||||
|-----|---------|
|
||||
| TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
|
||||
| Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
|
||||
| Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
|
||||
| Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
|
||||
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) |
|
||||
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) |
|
||||
| Docker | bereits auf TrueNAS Goldeye vorhanden |
|
||||
| Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Erstinstallation auf TrueNAS
|
||||
## 3. Erstinstallation auf TrueNAS Goldeye
|
||||
|
||||
### 3.1 ZFS-Datasets anlegen
|
||||
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen
|
||||
|
||||
In TrueNAS → **Datasets** → **Dataset hinzufügen** (je einmal wiederholen):
|
||||
|
||||
| Dataset-Name | Empfohlener Pfad | Verwendung |
|
||||
|---|---|---|
|
||||
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
|
||||
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
|
||||
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
|
||||
|
||||
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
|
||||
|
||||
### 3.2 Repository klonen
|
||||
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH):
|
||||
|
||||
```bash
|
||||
# SSH in TrueNAS oder lokale Shell
|
||||
git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
|
||||
# Vier Ordner anlegen
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
|
||||
|
||||
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
|
||||
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
|
||||
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
|
||||
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
|
||||
```
|
||||
|
||||
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
|
||||
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
|
||||
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
|
||||
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
|
||||
|
||||
### 3.2 Registry-Login auf der NAS
|
||||
|
||||
```bash
|
||||
docker login git.rismer.de
|
||||
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
|
||||
```
|
||||
|
||||
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
|
||||
|
||||
### 3.3 Repository klonen
|
||||
|
||||
```bash
|
||||
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
|
||||
cd /opt/gerbilmanager
|
||||
```
|
||||
|
||||
### 3.3 Konfiguration anlegen
|
||||
### 3.4 Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp deploy/truenas/.env.example deploy/truenas/.env
|
||||
# Jetzt .env bearbeiten:
|
||||
nano deploy/truenas/.env
|
||||
```
|
||||
|
||||
Mindestens setzen:
|
||||
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
|
||||
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
|
||||
|
||||
### 3.4 Images bauen und App starten
|
||||
| Variable | Wert |
|
||||
|----------|------|
|
||||
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
|
||||
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
|
||||
| `AI__ApiKey` | Dein Gemini API-Key |
|
||||
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
|
||||
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
|
||||
|
||||
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
|
||||
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
|
||||
|
||||
### 3.5 Images ziehen und App starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Erster Start dauert ca. 2–3 Minuten (Postgres-Init + EF-Migrationen).
|
||||
|
||||
### 3.5 Prüfen
|
||||
> **TrueNAS Goldeye Custom App (Alternative):**
|
||||
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
|
||||
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
|
||||
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
|
||||
|
||||
### 3.6 Verifikation
|
||||
|
||||
```bash
|
||||
# Alle Container laufen?
|
||||
# Alle 4 Container laufen?
|
||||
docker compose -f deploy/truenas/compose.yaml ps
|
||||
|
||||
# API-Healthcheck
|
||||
curl http://localhost/api/health
|
||||
# API-Healthcheck (erwartet: {"status":"Healthy"})
|
||||
curl -s http://localhost/api/health
|
||||
|
||||
# Webapp im Browser
|
||||
http://<NAS-IP>/
|
||||
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import)
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# API-Doku (Scalar) im Browser
|
||||
http://<NAS-IP>/scalar
|
||||
|
||||
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
|
||||
```
|
||||
|
||||
---
|
||||
@@ -125,6 +164,7 @@ http://<NAS-IP>/
|
||||
### Starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
@@ -134,26 +174,17 @@ docker compose -f deploy/truenas/compose.yaml up -d
|
||||
docker compose -f deploy/truenas/compose.yaml down
|
||||
```
|
||||
|
||||
### Aktualisieren (nach `git push` auf main)
|
||||
### Aktualisieren (nach CI-Push auf main)
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml build
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
|
||||
|
||||
### Mit Gitea CI (wenn Actions aktiviert)
|
||||
|
||||
Push auf `main` triggert automatisch Build → Test → Image-Push.
|
||||
Danach auf der NAS:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Backup & Wiederherstellung
|
||||
@@ -165,12 +196,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
|
||||
- Komprimiertes Foto-Archiv als `.tar.gz`
|
||||
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
|
||||
|
||||
Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
|
||||
Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/`
|
||||
|
||||
```
|
||||
/mnt/SSD/gerbil/backups/
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/
|
||||
2026-06-06_03-00/
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank)
|
||||
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL)
|
||||
photos_2026-06-06_03-00.tar.gz (Fotos)
|
||||
backup.log (Protokoll)
|
||||
```
|
||||
@@ -184,52 +215,68 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
|
||||
### Backup-Log prüfen
|
||||
|
||||
```bash
|
||||
tail -50 /mnt/SSD/gerbil/backups/backup.log
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
```
|
||||
|
||||
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
|
||||
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
|
||||
|
||||
### Wiederherstellung — Runbook
|
||||
|
||||
> **WARNUNG:** Alle aktuellen Daten werden überschrieben!
|
||||
> **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben!
|
||||
|
||||
**Schritt 1:** App stoppen (optional, aber empfohlen)
|
||||
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter)
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml stop api frontend
|
||||
```
|
||||
|
||||
**Schritt 2:** Restore ausführen
|
||||
```bash
|
||||
# Neuestes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh
|
||||
|
||||
# Bestimmtes Backup wiederherstellen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-05_03-00
|
||||
```bash
|
||||
# Neuestes Backup automatisch wählen und bestätigen:
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh latest -f
|
||||
|
||||
# Bestimmtes Backup (Datum aus Verzeichnisname):
|
||||
docker compose -f deploy/truenas/compose.yaml exec -T backup \
|
||||
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
1. Trennt alle offenen DB-Verbindungen
|
||||
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
|
||||
3. Entpackt das Foto-Archiv nach `/data/photos`
|
||||
|
||||
**Schritt 3:** API neu starten
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml start api frontend
|
||||
```
|
||||
|
||||
**Schritt 4:** Prüfen
|
||||
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):**
|
||||
```bash
|
||||
curl http://localhost/api/color-varieties | grep -c '"id"'
|
||||
# Erwarteter Wert: 73
|
||||
# Tier-Anzahl prüfen
|
||||
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*'
|
||||
|
||||
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
|
||||
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
|
||||
|
||||
# Foto stichprobenartig prüfen
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
|
||||
```
|
||||
|
||||
### Restore-Nachweis (Round-Trip-Test)
|
||||
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06)
|
||||
|
||||
Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
|
||||
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres:
|
||||
```
|
||||
73 ColorVarieties vorhanden
|
||||
→ DELETE 12 Zeilen → 61 verbleibend
|
||||
→ pg_restore eingespielt
|
||||
→ psql < dump.sql eingespielt
|
||||
→ 73 ColorVarieties bestätigt
|
||||
Exit-Code: 0
|
||||
```
|
||||
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
|
||||
**Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
|
||||
|
||||
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl
|
||||
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
|
||||
|
||||
---
|
||||
|
||||
@@ -240,15 +287,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
|
||||
|
||||
### Empfohlene Snapshot-Konfiguration
|
||||
|
||||
In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **Snapshots** → **Regelmäßige Snapshots**:
|
||||
|
||||
| Dataset | Häufigkeit | Aufbewahrung |
|
||||
|---------|-----------|--------------|
|
||||
| `gerbil/photos` | Stündlich | 24 Stunden |
|
||||
| `gerbil/photos` | Täglich | 30 Tage |
|
||||
| `gerbil/pgdata` | Stündlich | 24 Stunden |
|
||||
| `gerbil/pgdata` | Täglich | 30 Tage |
|
||||
| `gerbil/backups` | Täglich | 90 Tage |
|
||||
| Unterordner | Häufigkeit | Aufbewahrung |
|
||||
|-------------|-----------|--------------|
|
||||
| `.../photos` | Stündlich | 24 Stunden |
|
||||
| `.../photos` | Täglich | 30 Tage |
|
||||
| `.../pgdata` | Stündlich | 24 Stunden |
|
||||
| `.../pgdata` | Täglich | 30 Tage |
|
||||
| `.../backups` | Täglich | 90 Tage |
|
||||
|
||||
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
|
||||
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
|
||||
@@ -257,94 +304,93 @@ In TrueNAS → **Datasets** → Dataset auswählen → **Snapshots** → **Regel
|
||||
### Snapshot manuell erstellen (z. B. vor Update)
|
||||
|
||||
```bash
|
||||
# TrueNAS CLI
|
||||
zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
|
||||
# Pool-/Dataset-Name anpassen falls nötig
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d)
|
||||
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Aus ZFS-Snapshot wiederherstellen (Fotos)
|
||||
|
||||
```bash
|
||||
# Snapshot auflisten
|
||||
zfs list -t snapshot SSD/gerbil/photos
|
||||
# Snapshots auflisten
|
||||
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos
|
||||
|
||||
# Datei aus Snapshot kopieren
|
||||
cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
|
||||
# Einzelne Datei aus Snapshot kopieren
|
||||
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \
|
||||
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD via Gitea Actions
|
||||
|
||||
Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
|
||||
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und
|
||||
`git.rismer.de/gulum/gerbilmanager-frontend`.
|
||||
|
||||
### Aktivierung
|
||||
### Registry-Secrets in Gitea
|
||||
|
||||
1. **Gitea Actions aktivieren:**
|
||||
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
|
||||
2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|
||||
```bash
|
||||
# Gitea Runner Container (einfachste Variante für TrueNAS)
|
||||
docker run -d --name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
-e GITEA_INSTANCE_URL=http://truenas:13000 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
|
||||
| Secret | Wert |
|
||||
|--------|------|
|
||||
| `REGISTRY_USER` | Gitea-Benutzername |
|
||||
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` |
|
||||
|
||||
3. **Registry-Secrets konfigurieren:**
|
||||
Gitea → Repository → Einstellungen → Secrets:
|
||||
- `REGISTRY_USER` — dein Gitea-Benutzername
|
||||
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
|
||||
### Update nach CI-Push
|
||||
|
||||
### Workflow nach Aktivierung
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea Actions: dotnet test + npm test + npm run build
|
||||
→ Bei Erfolg: docker build + push zu truenas:13000/gulum/
|
||||
→ Auf NAS: docker compose pull + up -d
|
||||
```bash
|
||||
# Auf der NAS nach erfolgreichem CI-Lauf:
|
||||
cd /opt/gerbilmanager
|
||||
git pull
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Fragen
|
||||
|
||||
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
|
||||
|
||||
| # | Frage | Auswirkung |
|
||||
|---|-------|-----------|
|
||||
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
|
||||
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
|
||||
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
|
||||
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
|
||||
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
|
||||
|
||||
---
|
||||
|
||||
## 9. Fehlerbehebung
|
||||
## 8. Fehlerbehebung
|
||||
|
||||
### App startet nicht
|
||||
|
||||
```bash
|
||||
# Logs aller Container
|
||||
docker compose -f deploy/truenas/compose.yaml logs
|
||||
|
||||
# Logs eines bestimmten Containers
|
||||
docker compose -f deploy/truenas/compose.yaml logs api
|
||||
docker compose -f deploy/truenas/compose.yaml logs db
|
||||
```
|
||||
|
||||
### Port 80 belegt
|
||||
|
||||
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
|
||||
|
||||
```bash
|
||||
# In deploy/truenas/.env:
|
||||
PORT=8080
|
||||
# Dann neu starten:
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
### Postgres startet nicht (Permission denied auf pgdata)
|
||||
|
||||
```bash
|
||||
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
|
||||
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
|
||||
docker compose -f deploy/truenas/compose.yaml restart db
|
||||
```
|
||||
|
||||
### Registry-Pull schlägt fehl
|
||||
|
||||
```bash
|
||||
# Neu einloggen:
|
||||
docker login git.rismer.de
|
||||
# Dann pull wiederholen:
|
||||
docker compose -f deploy/truenas/compose.yaml pull
|
||||
```
|
||||
|
||||
### Datenbank nicht erreichbar
|
||||
|
||||
```bash
|
||||
# DB-Container läuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps db
|
||||
|
||||
# Verbindung testen
|
||||
docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
psql -U postgres -d gerbilmanager -c "\dt"
|
||||
```
|
||||
@@ -352,27 +398,16 @@ docker compose -f deploy/truenas/compose.yaml exec db \
|
||||
### Backup-Fehler
|
||||
|
||||
```bash
|
||||
# Backup-Log prüfen
|
||||
cat /mnt/SSD/gerbil/backups/backup.log | tail -30
|
||||
|
||||
# Backup manuell starten (mit Fehlerausgabe)
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup \
|
||||
/bin/sh /scripts/backup.sh
|
||||
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log
|
||||
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh
|
||||
```
|
||||
|
||||
### Fotos werden nicht angezeigt
|
||||
|
||||
Prüfe ob das `photos`-Volume korrekt gemounted ist:
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
|
||||
```
|
||||
|
||||
### Container-Status zurücksetzen (Neustart)
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/truenas/compose.yaml restart api
|
||||
```
|
||||
|
||||
### Kompletter Neustart (Daten bleiben erhalten)
|
||||
|
||||
```bash
|
||||
|
||||
136
docs/web-deploy.md
Normal file
136
docs/web-deploy.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# GerbilManager — Oeffentliche Webseite (Self-Hosted, TrueNAS)
|
||||
|
||||
> **Zielgruppe:** Julian.
|
||||
> Die oeffentliche Seite (Jimdo-Ersatz) laeuft self-hosted auf der TrueNAS neben dem Manager.
|
||||
> Strato-Domain → DynDNS → IP → Julians nginx-Proxy → publicsite-Container (Port 8081).
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Internet
|
||||
| HTTPS/HTTP
|
||||
v
|
||||
Julians nginx-Reverse-Proxy (laeuft schon auf NAS)
|
||||
| proxy_pass http://127.0.0.1:8081
|
||||
v
|
||||
publicsite (nginx:alpine, Port 8081) ← liest nur: /usr/share/nginx/html/live/
|
||||
| (Shared Volume, read-only)
|
||||
| [POST /api/publish im Manager schreibt in dasselbe Volume]
|
||||
v
|
||||
api (.NET, Port 8080 intern) → schreibt: /data/publicsite/live/
|
||||
| (Shared Volume, read-write)
|
||||
v
|
||||
Manager (frontend-nginx, Port 80) ← LAN-only, NIE internet-exponiert
|
||||
```
|
||||
|
||||
**SICHERHEIT — harte Bedingung:**
|
||||
- Nur `publicsite` (Port 8081) wird ins Internet weitergeleitet.
|
||||
- Der Manager (API + Frontend, Port 80) hat KEINE Authentifizierung → LAN-only.
|
||||
- Der `publicsite`-nginx proxied NICHT auf die API — er serviert nur statisches HTML.
|
||||
|
||||
---
|
||||
|
||||
## Erstinstallation
|
||||
|
||||
### 1. Verzeichnis anlegen
|
||||
|
||||
```bash
|
||||
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
```
|
||||
|
||||
Das Verzeichnis wird von der API beschrieben (laeuft als root im Container) — keine ACL-Aenderung noetig.
|
||||
Beim ersten `POST /api/publish` legt die API automatisch `live/` und `_staging_new/` darunter an.
|
||||
|
||||
### 2. .env erganzen
|
||||
|
||||
In `deploy/truenas/.env` hinzufuegen (oder aus `.env.example` uebernehmen):
|
||||
|
||||
```env
|
||||
PUBLICSITE_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite
|
||||
PUBLICSITE_PORT=8081
|
||||
```
|
||||
|
||||
### 3. Compose-Stack neu starten
|
||||
|
||||
```bash
|
||||
cd /opt/gerbilmanager
|
||||
docker compose -f deploy/truenas/compose.yaml up -d
|
||||
```
|
||||
|
||||
Der neue `publicsite`-Container startet und serviert Port 8081.
|
||||
Solange noch nicht veroeffentlicht wurde, zeigt er einen 404 (live/-Verzeichnis leer).
|
||||
|
||||
### 4. Julians externen nginx konfigurieren
|
||||
|
||||
Inhalt von `deploy/truenas/vhost-snippet.conf` in den bestehenden nginx-Proxy einfuegen
|
||||
(als eigenen `server`-Block oder per `include`):
|
||||
|
||||
```bash
|
||||
# Auf der NAS, nginx-Konfigverzeichnis (z.B. /etc/nginx/conf.d/ oder sites-available):
|
||||
nano /etc/nginx/conf.d/gerbilmanager-public.conf
|
||||
# <DOMAIN> durch den tatsaechlichen Hostnamen ersetzen
|
||||
nginx -t && nginx -s reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seite veroeffentlichen (Publish-Ablauf)
|
||||
|
||||
1. Im Manager einloggen (http://\<NAS-IP\>/)
|
||||
2. Navigiere zu **Webseite** → Inhalte bearbeiten → **Veroeffentlichen**
|
||||
3. Klick auf "Veroeffentlichen" loest `POST /api/publish` aus.
|
||||
|
||||
**Was passiert intern:**
|
||||
```
|
||||
POST /api/publish
|
||||
→ API baut SiteSnapshot aus DB (alle Published-Seiten)
|
||||
→ SiteRenderer rendert Snapshot → HTML-Dateien (path → content Map)
|
||||
→ Schreibt Dateien nach /data/publicsite/_staging_new/
|
||||
→ Atomic Swap: _staging_new/ → live/ (rename = ein Syscall, nie halb-geschrieben)
|
||||
→ publicsite-nginx serviert beim naechsten Request sofort den neuen Stand
|
||||
→ Kein Container-Restart, kein Image-Rebuild, kein CI
|
||||
Response: { "filesPublished": N }
|
||||
```
|
||||
|
||||
**Endergebnis:** publicsite-nginx liest sofort den neuen Stand aus `live/`.
|
||||
|
||||
---
|
||||
|
||||
## Verifikation
|
||||
|
||||
```bash
|
||||
# publicsite-Container laeuft?
|
||||
docker compose -f deploy/truenas/compose.yaml ps publicsite
|
||||
|
||||
# Seite lokal abrufbar?
|
||||
curl -s http://localhost:8081/ | head -5
|
||||
|
||||
# live/-Verzeichnis gefuellt?
|
||||
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite/live/
|
||||
|
||||
# Oeffentlich erreichbar (nach DNS-Propagation)?
|
||||
curl -s http://<DOMAIN>/ | grep "Kleine Chaoten"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitstrennung (Pflichtcheck)
|
||||
|
||||
| Was | Port | Internet-exponiert? |
|
||||
|-----|------|---------------------|
|
||||
| Manager (api + frontend) | 80 | **NEIN** — LAN-only |
|
||||
| Oeffentliche Seite (publicsite) | 8081 | Ja, via Julians nginx-Proxy |
|
||||
| API-Doku (Scalar) | 80/scalar | **NEIN** — LAN-only |
|
||||
|
||||
Der Manager-nginx (gerbilmanager-frontend, Port 80) und die API (Port 8080 intern)
|
||||
sind NICHT in `vhost-snippet.conf` eingetragen und NICHT in Julians externem Proxy konfiguriert.
|
||||
Sie sind ausschliesslich im Heimnetz erreichbar.
|
||||
|
||||
---
|
||||
|
||||
## Hostname noch ausstehend
|
||||
|
||||
`<DOMAIN>` in `deploy/truenas/vhost-snippet.conf` ist ein Platzhalter.
|
||||
Julian nennt den Hostnamen/Subdomain → ersetzen und nginx neu laden.
|
||||
@@ -40,6 +40,25 @@ npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Farbschlag-Katalog (AR-5)
|
||||
|
||||
Der Katalog lebt in `src/genetics/catalog.ts` (Single Source of Truth).
|
||||
Nach jeder Änderung dort den Generator laufen lassen:
|
||||
|
||||
```bash
|
||||
npm run gen:catalog
|
||||
```
|
||||
|
||||
Erzeugt zwei Artefakte und committet beide:
|
||||
|
||||
| Datei | Notation | Verwendung |
|
||||
|---|---|---|
|
||||
| `src/genetics/colorVarietySeed.generated.json` | Klammer (`e[f]`, `c[chm]`) | UI-Dropdowns, Frontend-Suche |
|
||||
| `src/genetics/colorVarietySeed.backend.json` | Frozen symbols (`ef`, `cchm`) | EF-Seed-Migrationen (Pam, DATA-Lane) |
|
||||
|
||||
Der vitest-Drift-Guard (`catalog-drift.test.ts`) schlägt fehl, wenn
|
||||
`generated.json` nach einer Katalog-Änderung nicht aktualisiert wurde.
|
||||
|
||||
## E2E-Tests (QA-1, Playwright)
|
||||
|
||||
```bash
|
||||
@@ -56,4 +75,4 @@ npx playwright test --project=phone # nur Smartphone-Viewport (390px)
|
||||
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
||||
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
||||
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
||||
`src/strings/de.ts`.
|
||||
`src/strings/de.ts`.
|
||||
|
||||
79
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
79
gerbil-manager-web/e2e/farbschlag-sync.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/** 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('')
|
||||
})
|
||||
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
45
gerbil-manager-web/e2e/juvenile-details.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** 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()
|
||||
})
|
||||
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
46
gerbil-manager-web/e2e/litter-mortality.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** 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()
|
||||
})
|
||||
@@ -361,6 +361,26 @@ 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
|
||||
|
||||
@@ -74,6 +74,8 @@ 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(
|
||||
@@ -89,7 +91,7 @@ function gerbil(
|
||||
id,
|
||||
name,
|
||||
gender,
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth,
|
||||
dateOfDeath: null,
|
||||
causeOfDeath: null,
|
||||
@@ -106,6 +108,7 @@ function gerbil(
|
||||
notes: null,
|
||||
// BESTAND-FILTER: default = eigener Bestand; einzelne externe Ahnen unten gesetzt.
|
||||
isResident: true,
|
||||
profilePhotoUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +121,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' },
|
||||
{ ...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('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'),
|
||||
@@ -149,12 +152,27 @@ 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' },
|
||||
{ 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-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' },
|
||||
@@ -292,5 +310,6 @@ export function seedDb(): MockDb {
|
||||
mailConfigured: true,
|
||||
contracts: [],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
}
|
||||
}
|
||||
|
||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** 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()
|
||||
})
|
||||
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
@@ -53,3 +53,121 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
||||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||
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')
|
||||
// Titel zeigt '(ohne Namen)'
|
||||
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||||
// 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()
|
||||
})
|
||||
|
||||
58
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
58
gerbil-manager-web/e2e/status-model.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** 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')
|
||||
const statusSelect = page
|
||||
.locator('label.field', { has: page.locator(`span:text-is("${t.filters.status}")`) })
|
||||
.locator('select')
|
||||
await expect(statusSelect).toHaveValue('Breeding')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -172,3 +172,27 @@ 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()
|
||||
})
|
||||
|
||||
49
gerbil-manager-web/gen-seed.mts
Normal file
49
gerbil-manager-web/gen-seed.mts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Katalog-Generator — AR-5
|
||||
*
|
||||
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
|
||||
*
|
||||
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
|
||||
* → Quelle für UI-Dropdowns, Frontend-Suche.
|
||||
*
|
||||
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
|
||||
* → Quelle für künftige EF-Seed-Migrationen (Pam).
|
||||
* NICHT in Bracket-Notation ändern — Backend-Parser
|
||||
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
|
||||
*
|
||||
* Ausführen nach jeder Änderung an catalog.ts:
|
||||
* npm run gen:catalog
|
||||
*
|
||||
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
|
||||
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
|
||||
*/
|
||||
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
|
||||
import { LOCUS_ORDER } from './src/genetics/loci.ts'
|
||||
import type { Genotype } from './src/genetics/genotype.ts'
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
|
||||
function toInternalString(g: Genotype): string {
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// ── Display artefact (bracket notation) ─────────────────────────────────────
|
||||
const displayPath = './src/genetics/colorVarietySeed.generated.json'
|
||||
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
|
||||
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
|
||||
|
||||
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
|
||||
const backendSeed = BASE_COLORS.map((entry, i) => ({
|
||||
name: entry.name,
|
||||
...(entry.english !== undefined ? { english: entry.english } : {}),
|
||||
canonicalGenotype: toInternalString(representativeGenotype(entry)),
|
||||
sortOrder: i,
|
||||
...(entry.image !== undefined ? { image: entry.image } : {}),
|
||||
}))
|
||||
const backendPath = './src/genetics/colorVarietySeed.backend.json'
|
||||
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
|
||||
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)
|
||||
@@ -10,7 +10,8 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"e2e": "playwright test"
|
||||
"e2e": "playwright test",
|
||||
"gen:catalog": "npx tsx gen-seed.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
78
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
78
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/** 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
45
gerbil-manager-web/src/api/names.ts
Normal file
45
gerbil-manager-web/src/api/names.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** 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))
|
||||
}
|
||||
@@ -10,9 +10,11 @@
|
||||
export type Gender = 'unknown' | 'male' | 'female'
|
||||
export const GENDERS: Gender[] = ['unknown', 'male', 'female']
|
||||
|
||||
/** Gerbil.Status — C# enum { Active, Deceased, GivenAway, ForSale }. */
|
||||
export type GerbilStatus = 'Active' | 'Deceased' | 'GivenAway' | 'ForSale'
|
||||
export const GERBIL_STATUSES: GerbilStatus[] = ['Active', 'ForSale', 'Deceased', 'GivenAway']
|
||||
/** 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']
|
||||
|
||||
/** ISO date string "YYYY-MM-DD" (maps to C# DateOnly). */
|
||||
export type DateOnlyString = string
|
||||
@@ -56,6 +58,8 @@ 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. */
|
||||
@@ -122,6 +126,8 @@ 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. */
|
||||
@@ -133,6 +139,7 @@ export interface CreateLitter {
|
||||
notes?: string | null
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
deathsWithin8Weeks?: number | null
|
||||
}
|
||||
|
||||
export type UpdateLitter = Partial<CreateLitter>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
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: string
|
||||
icon: ReactNode
|
||||
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: '🐭' },
|
||||
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: <GerbilIcon size="1.25em" /> },
|
||||
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
||||
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
||||
]
|
||||
@@ -46,7 +48,7 @@ export default function AppShell() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="app-logo" aria-hidden="true">🐭</span>
|
||||
<span className="app-logo" aria-hidden="true"><GerbilIcon size="1.5rem" /></span>
|
||||
<h1 className="app-title">{de.app.title}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -43,16 +43,29 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
||||
) : (
|
||||
<>
|
||||
<ul className="farbschlag-cards">
|
||||
{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>
|
||||
))}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { de } from '../strings/de'
|
||||
import { ALL_TRAITS } from '../format/traits'
|
||||
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||
import './charakterbogen.css'
|
||||
|
||||
export interface CharakterbogenProps {
|
||||
@@ -10,12 +10,6 @@ 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,
|
||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
||||
const next = new Set(selected)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
// Preserve the vocabulary order for stable output.
|
||||
// Preserve vocabulary (category) order for stable output.
|
||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="charakterbogen">
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
<label className="field">
|
||||
<span>{t.noteLabel}</span>
|
||||
<textarea
|
||||
|
||||
37
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
37
gerbil-manager-web/src/components/GerbilIcon.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
||||
.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__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;
|
||||
}
|
||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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[]
|
||||
|
||||
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||
const t = de.namegen
|
||||
const [letter, setLetter] = useState('')
|
||||
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: gender || undefined,
|
||||
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__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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.charakterbogen .trait-grid {
|
||||
list-style: none;
|
||||
margin: 0 0 0.75rem;
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -39,3 +52,16 @@
|
||||
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;
|
||||
}
|
||||
|
||||
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,23 @@
|
||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
||||
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
|
||||
import { de } from '../strings/de'
|
||||
|
||||
export const ALL_TRAITS = de.character.traits
|
||||
export interface TraitEntry {
|
||||
key: string
|
||||
label: string
|
||||
warn?: true
|
||||
}
|
||||
|
||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
||||
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]))
|
||||
|
||||
export function traitLabel(key: string): string {
|
||||
return LABEL_BY_KEY.get(key) ?? key
|
||||
@@ -13,3 +27,7 @@ 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
|
||||
* aus catalog.ts übereinstimmen.
|
||||
*
|
||||
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
|
||||
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
|
||||
* und die geänderten JSON-Dateien committen.
|
||||
*/
|
||||
import { readFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { CATALOG } from '../catalog'
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
describe('AR-5 Catalog drift-guard', () => {
|
||||
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
|
||||
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
|
||||
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
|
||||
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
|
||||
expect(committed).toEqual(Array.from(CATALOG))
|
||||
})
|
||||
})
|
||||
@@ -46,8 +46,8 @@ describe('Fraction', () => {
|
||||
describe('Genotype serialization', () => {
|
||||
it('round-trips display string <-> structured form', () => {
|
||||
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
|
||||
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
||||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
|
||||
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp')
|
||||
})
|
||||
|
||||
it('parses multi-char C-series alleles via maximal munch', () => {
|
||||
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
|
||||
expect(g.P).toEqual(['P', 'p'])
|
||||
})
|
||||
|
||||
it('wild type is AA CC DD EE GG PP spsp rere', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
|
||||
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
const only = result.offspring[0]
|
||||
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
|
||||
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp')
|
||||
expect(only.probability.text).toBe('1')
|
||||
expect(result.warnings).toHaveLength(0)
|
||||
})
|
||||
@@ -179,16 +179,16 @@ describe('Farbschlag catalog', () => {
|
||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||
})
|
||||
|
||||
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('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('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
// The first 18 are the frozen ColorVariety keys — their representative
|
||||
// genotype MUST resolve back to their own name, never a later variety.
|
||||
// The first 17 frozen ColorVariety keys — their representative genotype
|
||||
// MUST resolve back to their own name, never a later variety.
|
||||
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly:
|
||||
// chch+pp → REW engine check → 'REW' = entry.name.
|
||||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
||||
}
|
||||
@@ -220,10 +220,12 @@ describe('Farbschlag catalog', () => {
|
||||
|
||||
it('CATALOG seed view mirrors the ColorVariety table shape', () => {
|
||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 })
|
||||
// Every row has a non-empty canonical genotype display string and unique name.
|
||||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
|
||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
})
|
||||
|
||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||
@@ -284,7 +286,7 @@ describe('GEN-3a: Uw=G alias', () => {
|
||||
it('always RENDERS G, never Uw (breeder preference)', () => {
|
||||
// Uw/uw is an input/import alias only; output must echo G/g.
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
|
||||
'AA CC DD EE Gg PP spsp rere',
|
||||
'AA CC DD EE Gg PP spsp',
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
|
||||
})
|
||||
@@ -296,10 +298,16 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
|
||||
})
|
||||
|
||||
it('toDisplayString omits wild-type Sls but shows Slsl', () => {
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
|
||||
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => {
|
||||
// GEN-4d: Re (rere) omitted at wildtype, like Sls.
|
||||
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
|
||||
// Rex het → Rere shown
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
|
||||
'AA CC DD EE GG PP spsp Rere',
|
||||
)
|
||||
// WP → Slsl shown, rere still omitted
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
|
||||
'AA CC DD EE GG PP spsp rere Slsl',
|
||||
'AA CC DD EE GG PP spsp Slsl',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -329,7 +337,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
|
||||
describe('GEN-3a: flag/metadata tokens tolerated', () => {
|
||||
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
|
||||
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
|
||||
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp')
|
||||
})
|
||||
|
||||
it('extractGenotypeFlags reads deafness + tags', () => {
|
||||
@@ -362,19 +370,21 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||
it("accepts '-' input, stores '?', displays '-'", () => {
|
||||
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
|
||||
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
|
||||
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-'
|
||||
})
|
||||
it("'?' and '-' inputs are equivalent", () => {
|
||||
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
|
||||
'Aa C- DD EE GG Pp spsp rere',
|
||||
'Aa C- DD EE GG Pp spsp',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
||||
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).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
'Kohlfuchsschimmel',
|
||||
)
|
||||
})
|
||||
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||
@@ -389,10 +399,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
|
||||
|
||||
describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
|
||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
|
||||
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya.
|
||||
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
|
||||
expect(name).toBe('Schwarz')
|
||||
expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||
expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name)
|
||||
})
|
||||
|
||||
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
|
||||
@@ -400,9 +410,11 @@ 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 -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
|
||||
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.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
'Kohlfuchsschimmel',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -420,7 +432,8 @@ 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')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-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')
|
||||
})
|
||||
|
||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
@@ -470,8 +483,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')
|
||||
// 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')
|
||||
// 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')
|
||||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
@@ -480,11 +493,12 @@ 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('CP-Fuchs-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -506,7 +520,8 @@ 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')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-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 efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||
})
|
||||
|
||||
@@ -523,3 +538,152 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
|
||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
)
|
||||
// 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',
|
||||
)
|
||||
// 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',
|
||||
)
|
||||
})
|
||||
|
||||
// ── E-locus display order: E > e > e[f] ─────────────────────────────
|
||||
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
|
||||
// Stored canonical: [ef, e] (ef dominant over e in storage).
|
||||
// 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')
|
||||
})
|
||||
|
||||
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',
|
||||
)
|
||||
})
|
||||
|
||||
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',
|
||||
)
|
||||
})
|
||||
|
||||
// ── 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'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.E).toEqual(['ef', 'e'])
|
||||
expect(g.C).toEqual(['C', '?'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
// 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'
|
||||
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'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
expect(g.D).toEqual(['d', 'd'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
})
|
||||
|
||||
// ── Parser accepts both forms ─────────────────────────────────────────
|
||||
it('bracket input round-trips identically to internal-symbol input', () => {
|
||||
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
|
||||
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
|
||||
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
|
||||
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
|
||||
)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||||
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
|
||||
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
|
||||
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
||||
const g = fromDisplayString(input)
|
||||
expect(g.E).toEqual(['e', '?'])
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
|
||||
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception
|
||||
// to the frozen-name rule; the ColorVariety seed drops it too.
|
||||
{ name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||
{ name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
|
||||
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
|
||||
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
|
||||
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
|
||||
@@ -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: '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' },
|
||||
{ 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' },
|
||||
|
||||
// ── 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,16 +78,24 @@ 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 (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' },
|
||||
// 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' },
|
||||
{ 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: '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' },
|
||||
{ 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' } },
|
||||
// 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' },
|
||||
@@ -104,8 +112,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: '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' },
|
||||
{ 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' },
|
||||
|
||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||
@@ -147,10 +155,12 @@ 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 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.)
|
||||
* 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.
|
||||
*/
|
||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||
@@ -162,7 +172,9 @@ 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'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
||||
// 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'
|
||||
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||
}
|
||||
return dominantAllele(locus, x, y)
|
||||
@@ -175,10 +187,12 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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).
|
||||
*/
|
||||
function eFamily(g: Genotype): string | null {
|
||||
const [x, y] = g.E
|
||||
@@ -205,7 +219,10 @@ 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)
|
||||
return base?.name ?? family
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +250,13 @@ 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'] }))
|
||||
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
||||
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'}`
|
||||
}
|
||||
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
@@ -241,8 +264,30 @@ 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)
|
||||
if (!baseName) {
|
||||
// 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)) {
|
||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||
}
|
||||
const name = [baseName, ...modifiers].join(' ')
|
||||
|
||||
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
@@ -0,0 +1,426 @@
|
||||
[
|
||||
{
|
||||
"name": "REW",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch 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 chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"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 rere",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"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 rere",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"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 rere",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"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 rere",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"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 rere",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Platin",
|
||||
"english": "dd Lilac",
|
||||
"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 rere",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"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 rere",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"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 rere",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"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 rere",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Agouti",
|
||||
"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 rere",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Kohlfuchs",
|
||||
"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 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,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Topas",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 54,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 56,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 61,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 63,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 66
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 67
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"sortOrder": 68
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"sortOrder": 69
|
||||
}
|
||||
]
|
||||
@@ -1,406 +1,426 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"name": "REW",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti",
|
||||
"english": "Golden Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG PP spsp",
|
||||
"sortOrder": 5,
|
||||
"image": "agouti-mit-erklaerung-der-genloci.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Schwarz",
|
||||
"english": "Black",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG PP spsp",
|
||||
"sortOrder": 6,
|
||||
"image": "schwarz.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti",
|
||||
"english": "Grey Agouti",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE gg PP spsp",
|
||||
"sortOrder": 7,
|
||||
"image": "silberagouti.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit",
|
||||
"english": "Slate",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE gg PP spsp",
|
||||
"sortOrder": 8,
|
||||
"image": "anthrazit.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs",
|
||||
"english": "Dark-Eyed Honey",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||
"sortOrder": 9,
|
||||
"image": "algierfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blau",
|
||||
"english": "Blue",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE GG PP spsp",
|
||||
"sortOrder": 10,
|
||||
"image": "blau-schwarz-dd.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Gold",
|
||||
"english": "Argente Golden",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 11,
|
||||
"image": "gold.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin",
|
||||
"english": "Lilac",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 12,
|
||||
"image": "platin.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs",
|
||||
"english": "Yellow Fox",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||
"sortOrder": 13,
|
||||
"image": "goldfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs",
|
||||
"english": "Argente Nutmeg",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||
"sortOrder": 14,
|
||||
"image": "rotfuchs.JPG"
|
||||
},
|
||||
{
|
||||
"name": "dd Gold",
|
||||
"name": "Dilute Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd Platin",
|
||||
"name": "Dilute Platin",
|
||||
"english": "dd Lilac",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd EE GG pp spsp",
|
||||
"sortOrder": 16,
|
||||
"image": "platin-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Altweiss (REW)",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE gg pp spsp",
|
||||
"sortOrder": 17,
|
||||
"image": "altweiss-rew.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Apricot (Blassfuchs)",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg pp spsp",
|
||||
"sortOrder": 18,
|
||||
"image": "apricot-blassfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||
"sortOrder": 19,
|
||||
"image": "blaufuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "C-Separator",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee gg pp spsp",
|
||||
"sortOrder": 20,
|
||||
"image": "c-separator.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Elfenbein",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE gg pp spsp",
|
||||
"sortOrder": 21,
|
||||
"image": "elfenbein.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 22,
|
||||
"image": "kohlfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||
"sortOrder": 23,
|
||||
"image": "polarfuchs.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Topas",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 26,
|
||||
"image": "topas.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"name": "Dilute Agouti",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"name": "Dilute Silberagouti",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"name": "Dilute Kohlfuchs",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"name": "Dilute Anthrazit",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Dilute Algierfuchs",
|
||||
"canonicalGenotype": "AA CC dd ee GG PP spsp",
|
||||
"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 efef gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||
"sortOrder": 36,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 41,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 38,
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp",
|
||||
"sortOrder": 42,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 40,
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp",
|
||||
"sortOrder": 44,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp",
|
||||
"sortOrder": 45,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp",
|
||||
"sortOrder": 47,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp",
|
||||
"sortOrder": 49,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 46,
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp",
|
||||
"sortOrder": 50,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp",
|
||||
"sortOrder": 51,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 48,
|
||||
"name": "Dilute Topas",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp",
|
||||
"sortOrder": 52,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp",
|
||||
"sortOrder": 54,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 55,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp",
|
||||
"sortOrder": 56,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp",
|
||||
"sortOrder": 57,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp",
|
||||
"sortOrder": 59,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp",
|
||||
"sortOrder": 61,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp",
|
||||
"sortOrder": 63,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"sortOrder": 61
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"sortOrder": 63
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp",
|
||||
"sortOrder": 65
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp",
|
||||
"sortOrder": 66
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp",
|
||||
"sortOrder": 67
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 68
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp",
|
||||
"sortOrder": 69
|
||||
}
|
||||
]
|
||||
|
||||
@@ -75,18 +75,53 @@ export function wildType(): Genotype {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: breeder bracket-notation display symbols.
|
||||
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
|
||||
*/
|
||||
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
|
||||
ef: 'e[f]',
|
||||
cchm: 'c[chm]',
|
||||
ch: 'c[h]',
|
||||
}
|
||||
function displaySymbol(allele: string): string {
|
||||
return DISPLAY_SYMBOL[allele] ?? allele
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
|
||||
* Storage/dominance order is E > ef > e; display swaps ef and e so that
|
||||
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
|
||||
*/
|
||||
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
|
||||
|
||||
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
||||
if (locus !== 'E') return pair
|
||||
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
|
||||
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp".
|
||||
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a
|
||||
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the
|
||||
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy
|
||||
* 8-locus strings and the colour catalog stay byte-identical; it only appears for
|
||||
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing
|
||||
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder
|
||||
* convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display
|
||||
* order E > e > e[f].
|
||||
*/
|
||||
export function toDisplayString(g: Genotype): string {
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
(locus) =>
|
||||
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
|
||||
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||
.map((locus) => {
|
||||
const [a, b] = displayPair(locus, g[locus])
|
||||
return displaySymbol(a) + displaySymbol(b)
|
||||
})
|
||||
.map((s) => s.replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
@@ -146,8 +181,19 @@ function normalizeToken(tok: string): string | null {
|
||||
if (t === 'WP') t = 'Slsl'
|
||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
||||
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
||||
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
||||
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
||||
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
|
||||
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
|
||||
// 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(/\[-\]/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, '?')
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -539,28 +539,57 @@ textarea {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
/* GEN-4: wider min-width prevents long names (CP-Silberagouti-Hell) from overflowing */
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { getGerbil, updateGerbil } from '../api/gerbils'
|
||||
import { listLitters as listLittersPaged } from '../api/litters'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
|
||||
const enclosures = useApi(() => listEnclosures(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
const litters = useApi(() => listLitters(), [])
|
||||
const parentLitters = useApi(
|
||||
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||
[id],
|
||||
)
|
||||
|
||||
const colorName = useMemo(
|
||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||
@@ -237,6 +242,32 @@ export default function GerbilDetailPage() {
|
||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||
</div>
|
||||
|
||||
<h3>{t.detail.parentLittersTitle}</h3>
|
||||
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||
)}
|
||||
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||
<ul className="card-list">
|
||||
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||
<li key={litter.id}>
|
||||
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{litter.name}</span>
|
||||
<span className="badge badge--active">
|
||||
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||
</span>
|
||||
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||
{litter.totalBorn !== null && (
|
||||
<span className="gerbil-card__meta">
|
||||
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||
<div className="tabs" role="tablist">
|
||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
|
||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||
import { GENDERS, GERBIL_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||
import { GENDERS, SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
|
||||
import { useApi, useMutation } from '../hooks/useApi'
|
||||
import { genderLabel, statusLabel } from '../format/labels'
|
||||
import { fromDisplayString } from '../genetics'
|
||||
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
|
||||
import FarbschlagImage from '../components/FarbschlagImage'
|
||||
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||
import '../components/NameSuggestPanel.css'
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
@@ -32,7 +34,7 @@ interface FormState {
|
||||
const EMPTY: FormState = {
|
||||
name: '',
|
||||
gender: '',
|
||||
status: 'Active',
|
||||
status: 'Breeding',
|
||||
dateOfBirth: '',
|
||||
dateOfDeath: '',
|
||||
causeOfDeath: '',
|
||||
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||
|
||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
|
||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||
|
||||
<form className="form" onSubmit={onSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span>{t.fields.name} *</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
<div className="field">
|
||||
<label htmlFor="gerbil-name">{t.fields.name} *</label>
|
||||
<div className="namegen-name-row">
|
||||
<input
|
||||
id="gerbil-name"
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
aria-invalid={Boolean(errors.name)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => setShowNameSuggest((v) => !v)}
|
||||
>
|
||||
{de.namegen.button}
|
||||
</button>
|
||||
</div>
|
||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
||||
</label>
|
||||
</div>
|
||||
{showNameSuggest && (
|
||||
<NameSuggestPanel
|
||||
gender={form.gender}
|
||||
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
|
||||
onClose={() => setShowNameSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.gender} *</span>
|
||||
@@ -225,16 +245,29 @@ export default function GerbilFormPage() {
|
||||
{errors.gender && <small className="error-text">{errors.gender}</small>}
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.status}</span>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
|
||||
{GERBIL_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* STATUS-MODEL: Deceased/GivenAway are derived by the backend — show as read-only with hint */}
|
||||
{form.status === 'Deceased' || form.status === 'GivenAway' ? (
|
||||
<div className="field">
|
||||
<span>{t.fields.status}</span>
|
||||
<span className="muted">
|
||||
{statusLabel(form.status)}
|
||||
<small className="muted" style={{ marginLeft: '0.5rem' }}>
|
||||
({t.form.statusDerivedHint[form.status]})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{t.fields.status}</span>
|
||||
<select value={form.status} onChange={(e) => set('status', e.target.value as GerbilStatus)}>
|
||||
{SELECTABLE_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{statusLabel(s)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.dateOfBirth}</span>
|
||||
@@ -287,7 +320,14 @@ export default function GerbilFormPage() {
|
||||
<span className="farbschlag-value">
|
||||
<select
|
||||
value={form.colorVarietyId}
|
||||
onChange={(e) => set('colorVarietyId', e.target.value)}
|
||||
onChange={(e) => {
|
||||
const newId = e.target.value
|
||||
set('colorVarietyId', newId)
|
||||
if (newId) {
|
||||
const variety = (colorVarieties.data ?? []).find((cv) => cv.id === newId)
|
||||
if (variety?.canonicalGenotype) set('genotype', variety.canonicalGenotype)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{t.form.none}</option>
|
||||
{(colorVarieties.data ?? []).map((cv) => (
|
||||
@@ -367,7 +407,23 @@ export default function GerbilFormPage() {
|
||||
className="input"
|
||||
value={form.genotype}
|
||||
placeholder="Aa CC Dd EE GG Pp Spsp rere"
|
||||
onChange={(e) => set('genotype', e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
set('genotype', val)
|
||||
if (val.trim() !== '' && isGenotypeValid(val)) {
|
||||
try {
|
||||
const name = genotypeToFarbschlag(fromDisplayString(val))
|
||||
if (name === UNKNOWN_FARBSCHLAG) {
|
||||
set('colorVarietyId', '')
|
||||
} else {
|
||||
const match = (colorVarieties.data ?? []).find((cv) => cv.name === name)
|
||||
set('colorVarietyId', match?.id ?? '')
|
||||
}
|
||||
} catch {
|
||||
// invalid while typing — leave colorVarietyId unchanged
|
||||
}
|
||||
}
|
||||
}}
|
||||
aria-invalid={Boolean(errors.genotype)}
|
||||
/>
|
||||
<small className={errors.genotype ? 'error-text' : 'muted'}>
|
||||
|
||||
@@ -29,7 +29,7 @@ const SORT_ORDER_BY: Record<SortKey, string> = {
|
||||
export default function GerbilsPage() {
|
||||
const t = de.pages.gerbils
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState<GerbilStatus | ''>('Active')
|
||||
const [status, setStatus] = useState<GerbilStatus | ''>('Breeding')
|
||||
const [gender, setGender] = useState<Gender | ''>('')
|
||||
const [colorVarietyId, setColorVarietyId] = useState('')
|
||||
const [originBreeder, setOriginBreeder] = useState('')
|
||||
@@ -64,7 +64,7 @@ export default function GerbilsPage() {
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearch('')
|
||||
setStatus('Active')
|
||||
setStatus('Breeding')
|
||||
setGender('')
|
||||
setColorVarietyId('')
|
||||
setOriginBreeder('')
|
||||
@@ -85,7 +85,7 @@ export default function GerbilsPage() {
|
||||
|
||||
// UX-MOBILE-1: count non-default filter values for the badge.
|
||||
const activeFilterCount =
|
||||
(status !== 'Active' ? 1 : 0) +
|
||||
(status !== 'Breeding' ? 1 : 0) +
|
||||
(gender !== '' ? 1 : 0) +
|
||||
(colorVarietyId !== '' ? 1 : 0) +
|
||||
(originBreeder !== '' ? 1 : 0) +
|
||||
|
||||
@@ -21,13 +21,15 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import Tree from 'react-d3-tree'
|
||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import { API_BASE_URL, ApiError } from '../api/client'
|
||||
import { listLitters } from '../api/litters'
|
||||
import { listColorVarieties } from '../api/lookups'
|
||||
import { getInbreedingCoefficient } from '../api/pedigree'
|
||||
import type { Gender, Gerbil } from '../api/types'
|
||||
import type { Gender, Gerbil, Litter } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics'
|
||||
import {
|
||||
DEFAULT_GENERATIONS,
|
||||
ancestorsAt,
|
||||
@@ -156,6 +158,13 @@ export default function StammbaumPage() {
|
||||
? `${(inbreeding.data * 100).toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`
|
||||
: t.inbreeding.unavailable
|
||||
|
||||
/* ── Würfe des Wurzeltiers (STAMMBAUM-LITTERS): aktualisiert bei Umwurzeln ── */
|
||||
const rootLitters = useApi(
|
||||
() => listLitters({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||
[id],
|
||||
)
|
||||
const rootLitterItems = rootLitters.data?.items ?? []
|
||||
|
||||
/* ── react-d3-tree-Daten ── */
|
||||
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
||||
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
||||
@@ -276,7 +285,7 @@ export default function StammbaumPage() {
|
||||
<>
|
||||
<section className="page stammbaum-screen">
|
||||
<header className="page-head">
|
||||
<h2>{t.titleFor(root.gerbil.name)}</h2>
|
||||
<h2>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h2>
|
||||
<div className="head-actions">
|
||||
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||
{t.backToAnimal}
|
||||
@@ -315,27 +324,36 @@ export default function StammbaumPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stammbaum-canvas" ref={canvasRef}>
|
||||
{view && (
|
||||
<Tree
|
||||
key={id}
|
||||
data={datum}
|
||||
renderCustomNodeElement={renderNode}
|
||||
orientation="horizontal"
|
||||
pathFunc="step"
|
||||
translate={view.translate}
|
||||
zoom={view.zoom}
|
||||
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
|
||||
zoomable
|
||||
draggable
|
||||
collapsible={false}
|
||||
nodeSize={{ x: NODE_X, y: NODE_Y }}
|
||||
separation={{ siblings: 1, nonSiblings: 1 }}
|
||||
onUpdate={handleTreeUpdate}
|
||||
/>
|
||||
<div className="stammbaum-layout">
|
||||
{rootLitterItems.length > 0 && (
|
||||
<LittersPanel litters={rootLitterItems} t={t} />
|
||||
)}
|
||||
<div className="stammbaum-canvas" ref={canvasRef}>
|
||||
{view && (
|
||||
<Tree
|
||||
key={id}
|
||||
data={datum}
|
||||
renderCustomNodeElement={renderNode}
|
||||
orientation="horizontal"
|
||||
pathFunc="step"
|
||||
translate={view.translate}
|
||||
zoom={view.zoom}
|
||||
scaleExtent={{ min: ZOOM_MIN, max: ZOOM_MAX }}
|
||||
zoomable
|
||||
draggable
|
||||
collapsible={false}
|
||||
nodeSize={{ x: NODE_X, y: NODE_Y }}
|
||||
separation={{ siblings: 1, nonSiblings: 1 }}
|
||||
onUpdate={handleTreeUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="stammbaum-hint">{t.tapHint}</p>
|
||||
<ul className="stammbaum-hints">
|
||||
<li>{t.tapHint}</li>
|
||||
<li>{t.hintName}</li>
|
||||
<li>{t.hintExpand}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
||||
@@ -362,7 +380,14 @@ function PedigreeCard({
|
||||
const t = de.pages.stammbaum
|
||||
const g = node.gerbil
|
||||
const chip = farbschlag ? chipColorFor(farbschlag) : null
|
||||
const year = g.dateOfBirth ? g.dateOfBirth.slice(0, 4) : null
|
||||
const dob = g.dateOfBirth ? formatDate(g.dateOfBirth) : null
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
? g.profilePhotoUrl
|
||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||
: null
|
||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
||||
return (
|
||||
<div
|
||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||
@@ -370,24 +395,39 @@ function PedigreeCard({
|
||||
role={isRoot ? undefined : 'button'}
|
||||
title={isRoot ? undefined : t.tapHint}
|
||||
>
|
||||
{/* Foto-Platzhalter — echte Fotos kommen mit FEAT-6. */}
|
||||
<div className="pedigree-card__photo" aria-hidden="true">
|
||||
🐭
|
||||
<div className="pedigree-card__photo" aria-hidden={!photoUrl || undefined}>
|
||||
{photoUrl ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="pedigree-card__avatar"
|
||||
onError={() => setFailedUrl(rawUrl)}
|
||||
/>
|
||||
) : (
|
||||
<GerbilIcon size="1.6rem" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pedigree-card__body">
|
||||
<div className="pedigree-card__name">
|
||||
<SexIcon gender={g.gender} />
|
||||
<span className="pedigree-card__nametext">{g.name}</span>
|
||||
<Link
|
||||
to={`/rennmaeuse/${g.id}`}
|
||||
className="pedigree-card__nametext"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{g.name || de.pages.gerbils.nameless}
|
||||
</Link>
|
||||
</div>
|
||||
{farbschlag && (
|
||||
<span
|
||||
className="pedigree-chip"
|
||||
style={chip ? { background: chip.bg, color: chip.fg } : undefined}
|
||||
title={farbschlag}
|
||||
>
|
||||
{farbschlag}
|
||||
{g.genotype || farbschlag}
|
||||
</span>
|
||||
)}
|
||||
{year && <span className="pedigree-card__year">* {year}</span>}
|
||||
{dob && <span className="pedigree-card__year">* {dob}</span>}
|
||||
</div>
|
||||
{node.expandable && (
|
||||
<button
|
||||
@@ -407,6 +447,36 @@ function PedigreeCard({
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Würfe-Panel (STAMMBAUM-LITTERS) ─────────────────────────────── */
|
||||
|
||||
function LittersPanel({
|
||||
litters,
|
||||
t,
|
||||
}: {
|
||||
litters: Litter[]
|
||||
t: { littersTitle: string; littersJunge: string }
|
||||
}) {
|
||||
return (
|
||||
<aside className="stammbaum-litters-panel" aria-label={t.littersTitle}>
|
||||
<div className="stammbaum-litters-panel__title">{t.littersTitle}</div>
|
||||
<ul className="stammbaum-litters-panel__list">
|
||||
{litters.map((l) => (
|
||||
<li key={l.id}>
|
||||
<Link to={`/wuerfe/${l.id}`} className="stammbaum-litters-panel__link">
|
||||
<span className="stammbaum-litters-panel__name">{l.name}</span>
|
||||
{l.totalBorn != null && (
|
||||
<span className="stammbaum-litters-panel__born">
|
||||
{l.totalBorn} {t.littersJunge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function SexIcon({ gender }: { gender: Gender }) {
|
||||
const symbol = gender === 'male' ? '♂' : gender === 'female' ? '♀' : '?'
|
||||
return (
|
||||
@@ -439,7 +509,7 @@ function PrintPedigree({
|
||||
return (
|
||||
<div className="stammbaum-print">
|
||||
<header className="stammbaum-print__head">
|
||||
<h1>{t.titleFor(root.gerbil.name)}</h1>
|
||||
<h1>{t.titleFor(root.gerbil.name || de.pages.gerbils.nameless)}</h1>
|
||||
<p className="stammbaum-print__meta">
|
||||
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
||||
</p>
|
||||
@@ -494,7 +564,7 @@ function PrintCell({
|
||||
<div className={base} style={style}>
|
||||
<div className="stammbaum-print__name">
|
||||
{sexSymbol}
|
||||
{g.name}
|
||||
{g.name || de.pages.gerbils.nameless}
|
||||
</div>
|
||||
{g.dateOfBirth && (
|
||||
<div className="stammbaum-print__sub">
|
||||
@@ -502,7 +572,11 @@ function PrintCell({
|
||||
</div>
|
||||
)}
|
||||
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
|
||||
{g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
|
||||
{g.genotype && gen <= 2 && (
|
||||
<div className="stammbaum-print__geno">
|
||||
{toDisplayString(fromDisplayString(g.genotype))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
|
||||
checked={selectedIds.has(g.id)}
|
||||
onChange={() => toggleAnimal(g.id)}
|
||||
/>
|
||||
<span className="wizard-pick__name">{g.name}</span>
|
||||
<span className="wizard-pick__name">{g.name || de.pages.gerbils.nameless}</span>
|
||||
<span className="wizard-pick__meta">
|
||||
{[
|
||||
genderLabel(g.gender),
|
||||
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{de.pages.vertraege.fields.animals}</dt>
|
||||
<dd>{selectedAnimals.map((g) => g.name).join(', ')}</dd>
|
||||
<dd>{selectedAnimals.map((g) => g.name || de.pages.gerbils.nameless).join(', ')}</dd>
|
||||
</div>
|
||||
<div className="def-row">
|
||||
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||
|
||||
@@ -1,14 +1,62 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { getLitter } from '../api/litters'
|
||||
import { getGerbil, listGerbils } from '../api/gerbils'
|
||||
import { listColorVarieties, listContacts } from '../api/lookups'
|
||||
import { API_BASE_URL } from '../api/client'
|
||||
import { condition } from '../api/gridify'
|
||||
import { type Gerbil } from '../api/types'
|
||||
import { useApi } from '../hooks/useApi'
|
||||
import { formatDate } from '../format/labels'
|
||||
import { formatDate, genderLabel } from '../format/labels'
|
||||
import { isValidGenotype } from '../format/genotypeText'
|
||||
import { breed, fromDisplayString, type BreedingResult } from '../genetics'
|
||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||
import BreedingResultView from '../components/BreedingResultView'
|
||||
import GerbilIcon from '../components/GerbilIcon'
|
||||
import './wuerfe.css'
|
||||
|
||||
type JuvenileFields = (typeof de.pages.litters.detail.juvenileFields)
|
||||
|
||||
function JuvenileCard({ g, farbe, receiver, tJf }: {
|
||||
g: Gerbil
|
||||
farbe: string | null
|
||||
receiver: string | null
|
||||
tJf: JuvenileFields
|
||||
}) {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null)
|
||||
const rawUrl = g.profilePhotoUrl
|
||||
? /^(https?:|data:|\/\/)/.test(g.profilePhotoUrl)
|
||||
? g.profilePhotoUrl
|
||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||
: null
|
||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
||||
|
||||
return (
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card gerbil-card--with-photo">
|
||||
<div className="juvenile-photo">
|
||||
{photoUrl ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={g.name || ''}
|
||||
className="juvenile-photo__avatar"
|
||||
onError={() => setFailedUrl(rawUrl)}
|
||||
/>
|
||||
) : (
|
||||
<GerbilIcon size="1.4rem" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="juvenile-body">
|
||||
<span className="gerbil-card__name">{g.name}</span>
|
||||
<span className="gerbil-card__meta">{genderLabel(g.gender)}</span>
|
||||
{farbe && <span className="gerbil-card__meta">{tJf.colorVariety}: {farbe}</span>}
|
||||
{g.goHomeDate && <span className="gerbil-card__meta">{tJf.goHomeDate}: {formatDate(g.goHomeDate)}</span>}
|
||||
{receiver && <span className="gerbil-card__meta">{tJf.receiver}: {receiver}</span>}
|
||||
{g.dateOfDeath && <span className="gerbil-card__meta">{tJf.dateOfDeath}: {formatDate(g.dateOfDeath)}</span>}
|
||||
{g.causeOfDeath && <span className="gerbil-card__meta">{tJf.causeOfDeath}: {g.causeOfDeath}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WurfDetailPage() {
|
||||
const t = de.pages.litters
|
||||
@@ -43,6 +91,38 @@ export default function WurfDetailPage() {
|
||||
return breed(fromDisplayString(fg), fromDisplayString(mg))
|
||||
}, [father.data, mother.data])
|
||||
|
||||
// Farbschlag + Abnehmer lookups for the juvenile list.
|
||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||
const contacts = useApi(() => listContacts(), [])
|
||||
|
||||
const colorNameById = useMemo(
|
||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||
[colorVarieties.data],
|
||||
)
|
||||
const contactNameById = useMemo(
|
||||
() => new Map((contacts.data ?? []).map((c) => [c.id, c.name])),
|
||||
[contacts.data],
|
||||
)
|
||||
|
||||
const farbschlagOf = useCallback(
|
||||
(g: { colorVarietyId: string | null; genotype: string | null }): string | null => {
|
||||
if (g.colorVarietyId) {
|
||||
const name = colorNameById.get(g.colorVarietyId)
|
||||
if (name) return name
|
||||
}
|
||||
if (g.genotype?.trim()) {
|
||||
try {
|
||||
const name = genotypeToFarbschlag(fromDisplayString(g.genotype))
|
||||
return name === UNKNOWN_FARBSCHLAG ? null : name
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[colorNameById],
|
||||
)
|
||||
|
||||
if (litter.loading) return <p className="muted">{de.common.loading}</p>
|
||||
if (litter.error || !litter.data) {
|
||||
return (
|
||||
@@ -92,6 +172,12 @@ export default function WurfDetailPage() {
|
||||
{l.totalBorn != null ? ` / ${l.totalBorn}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
{l.deathsWithin8Weeks != null && (
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.deathsWithin8Weeks}</dt>
|
||||
<dd>{l.deathsWithin8Weeks}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="def-row">
|
||||
<dt>{t.fields.expectedGoHomeDate}</dt>
|
||||
<dd>{formatDate(l.expectedGoHomeDate)}</dd>
|
||||
@@ -117,18 +203,19 @@ export default function WurfDetailPage() {
|
||||
{!juveniles.loading && registered === 0 && <p className="muted">{t.detail.noJuveniles}</p>}
|
||||
{registered > 0 && (
|
||||
<ul className="card-list">
|
||||
{(juveniles.data?.items ?? []).map((g) => (
|
||||
<li key={g.id}>
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">{g.name}</span>
|
||||
<span className="gerbil-card__meta">{de.pages.gerbils.genderLabels[g.gender]}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{(juveniles.data?.items ?? []).map((g) => {
|
||||
const farbe = farbschlagOf(g)
|
||||
const receiver = g.receiverContactId ? contactNameById.get(g.receiverContactId) ?? null : null
|
||||
const tJf = t.detail.juvenileFields
|
||||
return (
|
||||
<li key={g.id}>
|
||||
<JuvenileCard g={g} farbe={farbe} receiver={receiver} tJf={tJf} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<h3>{t.detail.expectedColors}</h3>
|
||||
{expected ? (
|
||||
<BreedingResultView result={expected} title={t.detail.expectedColors} />
|
||||
) : (
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FormState {
|
||||
motherId: string
|
||||
motherName: string
|
||||
totalBorn: string
|
||||
deathsWithin8Weeks: string
|
||||
expectedGoHomeDate: string
|
||||
notes: string
|
||||
}
|
||||
@@ -28,6 +29,7 @@ const EMPTY: FormState = {
|
||||
motherId: '',
|
||||
motherName: '',
|
||||
totalBorn: '',
|
||||
deathsWithin8Weeks: '',
|
||||
expectedGoHomeDate: '',
|
||||
notes: '',
|
||||
}
|
||||
@@ -71,10 +73,11 @@ export default function WurfFormPage() {
|
||||
name: l.name,
|
||||
date: l.date ?? '',
|
||||
fatherId: l.fatherId ?? '',
|
||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
|
||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
|
||||
motherId: l.motherId ?? '',
|
||||
motherName: l.motherId ? (nameById.get(l.motherId) ?? '') : '',
|
||||
motherName: l.motherId ? (nameById.get(l.motherId) || de.pages.gerbils.nameless) : '',
|
||||
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||
deathsWithin8Weeks: l.deathsWithin8Weeks != null ? String(l.deathsWithin8Weeks) : '',
|
||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||
notes: l.notes ?? '',
|
||||
})
|
||||
@@ -109,12 +112,14 @@ export default function WurfFormPage() {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
const totalBornNum = form.totalBorn.trim() === '' ? null : Number(form.totalBorn)
|
||||
const deathsNum = form.deathsWithin8Weeks.trim() === '' ? null : Number(form.deathsWithin8Weeks)
|
||||
const body: CreateLitter = {
|
||||
name: form.name.trim(),
|
||||
date: form.date,
|
||||
fatherId: nn(form.fatherId),
|
||||
motherId: nn(form.motherId),
|
||||
totalBorn: totalBornNum != null && Number.isFinite(totalBornNum) ? totalBornNum : null,
|
||||
deathsWithin8Weeks: deathsNum != null && Number.isFinite(deathsNum) ? deathsNum : null,
|
||||
expectedGoHomeDate: nn(form.expectedGoHomeDate),
|
||||
notes: nn(form.notes),
|
||||
}
|
||||
@@ -178,7 +183,7 @@ export default function WurfFormPage() {
|
||||
) : (
|
||||
<AnimalPicker
|
||||
gender="male"
|
||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name }))}
|
||||
onPick={(g) => setForm((f) => ({ ...f, fatherId: g.id, fatherName: g.name || de.pages.gerbils.nameless }))}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
@@ -199,7 +204,7 @@ export default function WurfFormPage() {
|
||||
) : (
|
||||
<AnimalPicker
|
||||
gender="female"
|
||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name }))}
|
||||
onPick={(g) => setForm((f) => ({ ...f, motherId: g.id, motherName: g.name || de.pages.gerbils.nameless }))}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
@@ -215,6 +220,26 @@ export default function WurfFormPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.deathsWithin8Weeks}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
value={form.deathsWithin8Weeks}
|
||||
onChange={(e) => set('deathsWithin8Weeks', e.target.value)}
|
||||
/>
|
||||
{(() => {
|
||||
const d = Number(form.deathsWithin8Weeks)
|
||||
const tb = Number(form.totalBorn)
|
||||
return form.deathsWithin8Weeks.trim() !== '' &&
|
||||
form.totalBorn.trim() !== '' &&
|
||||
d > tb ? (
|
||||
<small className="error-text">{t.validation.deathsExceedTotalBorn}</small>
|
||||
) : null
|
||||
})()}
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.expectedGoHomeDate}</span>
|
||||
<input
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user