Compare commits
29 Commits
feature/ge
...
fa44dc1f6c
| Author | SHA1 | Date | |
|---|---|---|---|
| fa44dc1f6c | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| 55c298ad1f | |||
| e1e088f041 | |||
| 358e30db1f | |||
| 0b9b03eb2d | |||
| d4c9be23c8 | |||
| f6bfc0ebab | |||
| 3160b66697 | |||
| 39767cef0f | |||
| c26ac5c6dd | |||
| 2c3f342905 |
@@ -48,6 +48,19 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||
|
||||
- name: dotnet-ef Tool installieren
|
||||
run: dotnet tool install --global dotnet-ef --version 10.0.*
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
# 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 -c Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -131,6 +131,9 @@ $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/
|
||||
|
||||
@@ -580,6 +580,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 = """
|
||||
[
|
||||
|
||||
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
145
GerbilManager.Tests/PartialUpdateTests.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>CR-2 regression: PUT /gerbils/{id} must not silently destroy fields that
|
||||
/// the caller omits (PATCH semantics). The ForSale-toggle and Charakterbogen-save
|
||||
/// send partial bodies — all other fields must be preserved unchanged.</summary>
|
||||
public class PartialUpdateTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PartialUpdateTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
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 static string? GetNested(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) ? v.ToString() : null;
|
||||
|
||||
/// <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,
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
originBreeder = "Zucht der Kleinen Chaoten",
|
||||
isDeaf = false,
|
||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||
characterNote = "Liebling der Familie",
|
||||
notes = "Eine Notiz",
|
||||
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ tests
|
||||
|
||||
[Fact]
|
||||
public async Task ForSale_toggle_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange: create a fully-populated gerbil
|
||||
var id = await CreateFullGerbil("ForSale-Tier");
|
||||
|
||||
// Act: send only { status: 'ForSale' } — exactly what the detail page does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}",
|
||||
JsonContent.Create(new { status = "ForSale" }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: re-read and check all fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
Assert.Equal("ForSale", GetStr(json, "status"));
|
||||
Assert.Equal("ForSale-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.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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CharacterSave_does_not_wipe_other_fields()
|
||||
{
|
||||
// Arrange
|
||||
var id = await CreateFullGerbil("Charakter-Tier");
|
||||
|
||||
// Act: send only { characterTraits, characterNote } — exactly what the Charakterbogen save does
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
characterTraits = new[] { "handzahm" },
|
||||
characterNote = "Neue Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// 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("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.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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EditForm_omitting_import_fields_keeps_them()
|
||||
{
|
||||
// Arrange: gerbil with import-set fields
|
||||
var id = await CreateFullGerbil("EditForm-Tier");
|
||||
|
||||
// Act: send a typical edit-form body that omits originBreeder/isDeaf/externalRef/characterTraits/characterNote
|
||||
var put = await _client.PutAsync($"/gerbils/{id}", JsonContent.Create(new
|
||||
{
|
||||
name = "EditForm-Tier (umbenannt)",
|
||||
gender = "female",
|
||||
genotype = "aa CC DD ee GG PP spsp rere",
|
||||
notes = "Aktualisierte Notiz",
|
||||
}));
|
||||
Assert.Equal(HttpStatusCode.NoContent, put.StatusCode);
|
||||
|
||||
// Assert: name updated, import fields survive
|
||||
var json = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{id}")).RootElement;
|
||||
|
||||
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.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"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_with_minimal_body_uses_safe_defaults()
|
||||
{
|
||||
// A minimal POST (only name) must still produce a valid gerbil
|
||||
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new { name = "Minimal" }));
|
||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
|
||||
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("Minimal", GetStr(json, "name"));
|
||||
Assert.Equal("Active", GetStr(json, "status"));
|
||||
// characterTraits defaults to [] on create when not provided
|
||||
Assert.Contains("[]", GetNested(json, "characterTraits") ?? "");
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,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 =>
|
||||
|
||||
@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
public record GerbilInput(
|
||||
string Name,
|
||||
Gender Gender,
|
||||
string? Name,
|
||||
Gender? Gender,
|
||||
GerbilStatus? Status,
|
||||
Guid? LitterId,
|
||||
Guid? OriginContactId,
|
||||
|
||||
@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name };
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
||||
});
|
||||
|
||||
// PUT /gerbils/{id}
|
||||
// PUT /gerbils/{id} — PATCH semantics: omitted/null fields keep the stored value.
|
||||
group.MapPut("/{id:guid}", async Task<Results<NoContent, NotFound>> (Guid id, GerbilInput input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
g.Name = input.Name;
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||
// for fields the frontend omits. A non-null input value always wins (including explicit
|
||||
// nulls — callers that want to clear a nullable field must send a full object; a
|
||||
// dedicated PATCH endpoint can be added later if point-clear is needed).
|
||||
private static void Apply(Gerbil g, GerbilInput i, bool isCreate)
|
||||
{
|
||||
g.Gender = i.Gender;
|
||||
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);
|
||||
g.LitterId = i.LitterId;
|
||||
g.OriginContactId = i.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate;
|
||||
g.Genotype = i.Genotype;
|
||||
g.Notes = i.Notes;
|
||||
g.ImportSource = i.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
||||
g.CharacterNote = i.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf;
|
||||
g.LitterId = i.LitterId ?? g.LitterId;
|
||||
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||
g.Genotype = i.Genotype ?? g.Genotype;
|
||||
g.Notes = i.Notes ?? g.Notes;
|
||||
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,12 @@ namespace GerbilManagerWebAPI.Import
|
||||
ResidencySummary? Residency = null);
|
||||
|
||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||
/// HerkunftBackfilled = resident animals whose null OriginBreeder was filled (safe, no overwrite).
|
||||
/// FarbschlagWouldRebackfill = already-imported animals with a stale ColorVariety that the
|
||||
/// current extract would update (no-op counter until the safety mechanism is ratified).</summary>
|
||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule,
|
||||
int HerkunftBackfilled = 0, int FarbschlagWouldRebackfill = 0);
|
||||
|
||||
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||
@@ -98,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,
|
||||
|
||||
@@ -137,31 +137,71 @@ namespace GerbilManagerWebAPI.Import
|
||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
||||
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||
var existingRows = await _db.Gerbils
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId }).ToListAsync();
|
||||
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.ExternalRef, g.LitterId, g.ColorVarietyId }).ToListAsync();
|
||||
var gidByNameDob = new Dictionary<string, Guid>();
|
||||
foreach (var g in existingRows)
|
||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||
.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));
|
||||
@@ -169,7 +209,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||
|
||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender));
|
||||
var currentColorVarietyId = exists && existingColorVarietyByExtRef.TryGetValue(a.Id, out var ecv) ? ecv : null;
|
||||
plan.Add(new AnimalPlan(a, gid, exists, wurfLitterId, currentLitter, colorVarietyId, gender, currentColorVarietyId));
|
||||
}
|
||||
|
||||
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||
@@ -460,6 +501,78 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// FARBSCHLAG-RE-MATCH — no-op counter (safety mechanism pending god/Julian sign-off).
|
||||
// Counts already-imported animals where the current extract matched a DIFFERENT
|
||||
// ColorVariety than what is currently stored in the DB. Does NOT update any row.
|
||||
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").
|
||||
const string DefaultClanBreeder = "Zucht der Kleinen Chaoten";
|
||||
int herkunftBackfilled = 0;
|
||||
{
|
||||
var needsHerkunft = await _db.Gerbils
|
||||
.Where(g => g.OriginBreeder == null && g.IsResident)
|
||||
.Select(g => new { g.Id, g.LitterId })
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var g in needsHerkunft)
|
||||
{
|
||||
herkunftBackfilled++;
|
||||
if (execute)
|
||||
{
|
||||
// Prefer the OriginBreeder of an existing parent (father first, then mother).
|
||||
string? derived = null;
|
||||
if (g.LitterId is not null)
|
||||
{
|
||||
var parentIds = await _db.Litters
|
||||
.Where(l => l.Id == g.LitterId)
|
||||
.Select(l => new { l.FatherId, l.MotherId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (parentIds?.FatherId is Guid fid)
|
||||
derived = await _db.Gerbils.Where(gb => gb.Id == fid && gb.OriginBreeder != null)
|
||||
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||
if (derived is null && parentIds?.MotherId is Guid mid)
|
||||
derived = await _db.Gerbils.Where(gb => gb.Id == mid && gb.OriginBreeder != null)
|
||||
.Select(gb => gb.OriginBreeder).FirstOrDefaultAsync();
|
||||
}
|
||||
var row = await _db.Gerbils.FindAsync(g.Id);
|
||||
if (row is not null && row.OriginBreeder is null)
|
||||
row.OriginBreeder = derived ?? DefaultClanBreeder;
|
||||
}
|
||||
}
|
||||
if (execute && herkunftBackfilled > 0) await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (littersWithoutDate > 0)
|
||||
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
|
||||
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||
@@ -469,9 +582,15 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (parentFksBackfilled > 0)
|
||||
notes.Add($"Parent-FK-Backfill: {parentFksBackfilled} bereits importierte Würfe haben jetzt eine Eltern-Verknüpfung (Elternteil war zuvor in Quarantäne, jetzt geladen).");
|
||||
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
|
||||
if (herkunftBackfilled > 0)
|
||||
notes.Add($"Herkunft-Backfill: {herkunftBackfilled} Bestand-Tier(e) mit leerem Herkunft-Feld befüllt (Zucht der Kleinen Chaoten oder von Elternteil abgeleitet). Niemals überschrieben.");
|
||||
if (farbschlagWouldRebackfill > 0)
|
||||
notes.Add($"Farbschlag-Hinweis (kein Overwrite): {farbschlagWouldRebackfill} bereits importierte Tier(e) haben einen veralteten Farbschlag, den der aktuelle Extraktor korrigieren würde — Overwrite-Mechanismus ausstehend (god/Julian Freigabe).");
|
||||
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(
|
||||
@@ -480,11 +599,12 @@ 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,
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||
}
|
||||
|
||||
private T? Load<T>(string file)
|
||||
@@ -517,6 +637,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
|
||||
@@ -574,7 +711,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||
private sealed record AnimalPlan(
|
||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender);
|
||||
Guid? CurrentLitterId, Guid? ColorVarietyId, Gender Gender,
|
||||
Guid? CurrentColorVarietyId = null);
|
||||
|
||||
/// <summary>A litter synthesized from chart-position parentRefs (PEDIGREE-LINK).</summary>
|
||||
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -807,6 +807,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.HasIndex("EnclosureId");
|
||||
|
||||
b.HasIndex("ExternalRef")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("LitterId");
|
||||
|
||||
b.HasIndex("OriginContactId");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using GerbilManagerWebAPI.Endpoints;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
@@ -46,7 +47,17 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// 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>();
|
||||
|
||||
@@ -15,9 +15,15 @@ TAG=latest
|
||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
||||
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
|
||||
KEYS_PATH=/mnt/SSD/gerbil/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=
|
||||
# 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-2.0-flash
|
||||
|
||||
@@ -40,10 +40,17 @@ 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-2.0-flash}"
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
- keys:/data/keys
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -101,6 +108,14 @@ volumes:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||
keys:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||
backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -55,7 +55,7 @@ export function skipUnlessLive() {
|
||||
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||
*/
|
||||
export async function gotoSection(page: Page, label: string) {
|
||||
const nav = page.getByRole('navigation')
|
||||
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||
// App noch nicht geladen (about:blank) -> erst zur Startseite
|
||||
if (!(await nav.isVisible())) {
|
||||
await page.goto('/')
|
||||
|
||||
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
90
gerbil-manager-web/e2e/gehege-regression.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* QA-REGRESSION-1: GEHEGE-RENAME Lock-in.
|
||||
*
|
||||
* Sichert das Verhalten von drei GEHEGE-RENAME-Änderungen ab, die leicht still
|
||||
* brechen können (Redirects, Nav-Einträge, Landing-URL):
|
||||
* (a) /becken/* → /gehege/* Redirect (bookmarks / externe Links)
|
||||
* (b) Nav zeigt 'Gehege' + 🏜️, kein 'Becken', kein 'Start'-Eintrag
|
||||
* (c) '/' (Index) landet auf /rennmaeuse (nicht mehr auf einer Startseite)
|
||||
*
|
||||
* Bereits abgedeckt (NICHT dupliziert):
|
||||
* - gotoSection(de.nav.enclosures) navigiert zu Gehege-Heading: navigation.spec.ts:19-20
|
||||
* - CRUD + 409 auf /gehege: becken-kontakte.spec.ts
|
||||
* - Wurf-Link + Herkunft: tiere.spec.ts:101-128
|
||||
*/
|
||||
import { de, expect, test } from './fixtures'
|
||||
|
||||
// ── (a) /becken/* → /gehege/* Redirect ──────────────────────────────────────
|
||||
|
||||
test('GEHEGE-RENAME: /becken leitet auf /gehege weiter (bookmark-safe)', async ({ page }) => {
|
||||
await page.goto('/becken')
|
||||
// React Router BeckenRedirect navigiert → /gehege; Gehege-Heading muss sichtbar sein
|
||||
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/gehege$/)
|
||||
})
|
||||
|
||||
test('GEHEGE-RENAME: /becken/neu leitet auf /gehege/neu weiter (deep redirect)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/becken/neu')
|
||||
// Neues-Gehege-Formular muss sichtbar sein, URL muss /gehege/neu sein
|
||||
await expect(page.getByRole('heading', { name: de.pages.becken.form?.createTitle ?? de.pages.becken.newButton })).toBeVisible({ timeout: 6_000 })
|
||||
await expect(page).toHaveURL(/\/gehege\/neu$/)
|
||||
})
|
||||
|
||||
// ── (b) Nav: 'Gehege' ✓, 'Becken' ✗, 'Start' ✗ ──────────────────────────────
|
||||
|
||||
test('GEHEGE-RENAME: Nav hat "Gehege"-Eintrag mit Terrarium-Icon, kein "Becken"', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/rennmaeuse')
|
||||
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||
|
||||
// Gehege-Link soll vorhanden sein (via „Mehr"-Blatt auf Phone oder direkt auf Desktop)
|
||||
// Wir öffnen „Mehr" um sekundäre Links sichtbar zu machen
|
||||
const moreBtn = nav.getByRole('button', { name: de.nav.more })
|
||||
if (await moreBtn.isVisible()) {
|
||||
await moreBtn.click()
|
||||
}
|
||||
await expect(nav.getByRole('link', { name: de.nav.enclosures })).toBeVisible()
|
||||
// "Becken" als separater Nav-Eintrag darf nicht mehr existieren
|
||||
await expect(nav.getByRole('link', { name: 'Becken', exact: true })).toBeHidden()
|
||||
})
|
||||
|
||||
test('GEHEGE-RENAME: Nav hat keinen "Start"-Eintrag mehr (de.nav.home entfernt)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/rennmaeuse')
|
||||
// Warten bis die Tierliste geladen ist, dann Nav prüfen
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
const nav = page.getByRole('navigation', { name: de.nav.mainNavigation })
|
||||
// 'Start' (de.nav.home wurde entfernt) darf kein Nav-Link mehr sein
|
||||
await expect(nav.getByRole('link', { name: 'Start', exact: true })).toBeHidden()
|
||||
})
|
||||
|
||||
// ── (c) '/' landet auf /rennmaeuse ──────────────────────────────────────────
|
||||
|
||||
test('GEHEGE-RENAME: "/" leitet auf /rennmaeuse weiter — keine Startseite mehr', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/')
|
||||
// URL muss /rennmaeuse sein (Navigate replace)
|
||||
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||
// Tierliste-Heading sichtbar — nicht die alte Startseite
|
||||
await expect(
|
||||
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||
).toBeVisible()
|
||||
// Alten Willkommen-Text darf es nicht mehr geben (de.pages.home wurde entfernt)
|
||||
await expect(page.getByText('Willkommen')).toBeHidden()
|
||||
})
|
||||
|
||||
test('GEHEGE-RENAME: 404-Seite "Zur Startseite" landet weiterhin auf Tierliste', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/diese-seite-gibt-es-nicht')
|
||||
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||
// Nach Klick auf "Zur Startseite" → '/' → redirect → /rennmaeuse
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/rennmaeuse$/)
|
||||
})
|
||||
302
gerbil-manager-web/e2e/live-data.spec.ts
Normal file
302
gerbil-manager-web/e2e/live-data.spec.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* QA-LIVE-DATA: Tiefe Begehung mit echten Import-Daten (325 Tiere, 865 Würfe, 138 Fotos).
|
||||
* Läuft ausschließlich im LIVE-Modus (E2E_BASE_URL gesetzt).
|
||||
*
|
||||
* Bekannte Datengrundlage (Stand Re-Import #2.5):
|
||||
* - Vance von den Kleinen Chaoten id=afdaff89-0274-4778-8f2f-3957a81bf58e (Resident, Stammbaum)
|
||||
* - Girasol of Topolino id=bf20f105-2ea9-475f-89b7-d50e59fb7d3b (9 Fotos)
|
||||
* - Litter 2026-02-10 id=a1db02cf-48ff-4f3a-8eab-33f2360c27f0 (beide Eltern gesetzt)
|
||||
* - 204 Residents, 121 Externe, 15 namenlose Einträge
|
||||
*/
|
||||
import { de, expect, gotoSection, openFilterPanel, skipUnlessLive, test } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
const tl = de.pages.litters
|
||||
const td = t.detail
|
||||
|
||||
// ── 1. RENNMÄUSE-LISTE ───────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Tiere-Liste lädt 325 Einträge (Bestand-Default-Filter inklusive externe)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
const rows = page.locator('.gerbil-row')
|
||||
await expect(rows.first()).toBeVisible()
|
||||
const count = await rows.count()
|
||||
// Alle 325 oder zumindest Paginierung der ersten Seite (>=20)
|
||||
expect(count).toBeGreaterThanOrEqual(20)
|
||||
// Count-Label sichtbar ("204 Tiere" für Bestand-Default-Filter oder "325 Tiere")
|
||||
await expect(page.locator('p.muted').filter({ hasText: t.countLabel })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Live-Data: Bestand-Filter sichtbar + umschaltbar (Resident vs. Alle)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
await openFilterPanel(page)
|
||||
// Filter-Panel oder mindestens der "Nur Bestand"-Toggle ist sichtbar
|
||||
const filterSection = page.locator('.filter-panel, [aria-label*="Filter"], .filter-panel__content')
|
||||
await expect(filterSection.first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('Live-Data: Suche nach "Vance" findet mindestens einen Treffer', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Vance')
|
||||
const rows = page.locator('.gerbil-row')
|
||||
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||
expect(await rows.count()).toBeGreaterThanOrEqual(1)
|
||||
// Name "Vance" soll in einem Ergebnis auftauchen
|
||||
await expect(page.locator('.gerbil-row').filter({ hasText: 'Vance' }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Live-Data: Suche nach "von den" (Namensmuster) liefert Treffer', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
await page.getByPlaceholder(t.searchPlaceholder).first().fill('von den')
|
||||
const rows = page.locator('.gerbil-row')
|
||||
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||
expect(await rows.count()).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
test('Live-Data: Genotyp-Tabelle scrollt horizontal (kein Page-Overflow bei 390px)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.locator('.gerbil-row').first()).toBeVisible()
|
||||
// Kein horizontaler Scroll auf body/html
|
||||
const bodyOverflow = await page.evaluate(() => {
|
||||
const body = document.body
|
||||
const html = document.documentElement
|
||||
return {
|
||||
bodyScrollWidth: body.scrollWidth,
|
||||
htmlScrollWidth: html.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
}
|
||||
})
|
||||
expect(bodyOverflow.bodyScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
|
||||
expect(bodyOverflow.htmlScrollWidth).toBeLessThanOrEqual(bodyOverflow.viewportWidth + 2)
|
||||
})
|
||||
|
||||
// ── 2. TIER-DETAIL ────────────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Girasol of Topolino Detail zeigt Fotos-Tab mit mindestens 9 Fotos', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/rennmaeuse/bf20f105-2ea9-475f-89b7-d50e59fb7d3b')
|
||||
await expect(page.getByRole('heading', { name: 'Girasol of Topolino' })).toBeVisible()
|
||||
// Fotos-Tab anklicken
|
||||
await page.getByRole('tab', { name: td.tabs.photos }).click()
|
||||
// Mindestens ein Foto-Img sichtbar (9 Fotos importiert)
|
||||
const photos = page.locator('img.photo-thumb, img[src*="/photos/files/"], .photo-item img, .photo-list img')
|
||||
await expect(photos.first()).toBeVisible({ timeout: 8_000 })
|
||||
expect(await photos.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Live-Data: Vance von den Kleinen Chaoten Detail rendert korrekt', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
|
||||
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
|
||||
// Gesundheits-Tab (leer OK, soll aber nicht crashen)
|
||||
await page.getByRole('tab', { name: td.tabs.health }).click()
|
||||
await expect(page).not.toHaveURL('/error')
|
||||
// Gewicht-Tab
|
||||
await page.getByRole('tab', { name: td.tabs.weight }).click()
|
||||
await expect(page).not.toHaveURL('/error')
|
||||
})
|
||||
|
||||
test('Live-Data: Stammbaum-Link von Tier-Detail navigiert zu StammbaumPage', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e')
|
||||
await expect(page.getByRole('heading', { name: 'Vance von den Kleinen Chaoten' })).toBeVisible()
|
||||
// Stammbaum-Link suchen
|
||||
const stammbaumLink = page.getByRole('link', { name: /Stammbaum/i }).first()
|
||||
if (await stammbaumLink.isVisible()) {
|
||||
await stammbaumLink.click()
|
||||
await expect(page).toHaveURL(/stammbaum/)
|
||||
} else {
|
||||
// Direkt navigieren
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||
}
|
||||
// Stammbaum-SVG oder rd3t-Knoten sichtbar
|
||||
await expect(page.locator('[data-testid="rd3t-svg"], svg.rd3t-svg, .stammbaum-page, svg').first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// ── 3. STAMMBAUM-SEITE ───────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Stammbaum rendert ohne Page-Overflow (390px)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||
// Irgendein SVG-Element muss sichtbar sein
|
||||
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||
const overflow = await page.evaluate(() => ({
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
}))
|
||||
expect(overflow.bodyScrollWidth).toBeLessThanOrEqual(overflow.viewportWidth + 2)
|
||||
})
|
||||
|
||||
test('Live-Data: Stammbaum hat Druck-Button', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||
const printBtn = page.locator('button, a').filter({ hasText: /Drucken|Print|Ahnentafel/i })
|
||||
await expect(printBtn.first()).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 4. WÜRFE-LISTE ───────────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Würfe-Liste zeigt mindestens 20 Einträge (865 Würfe importiert)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.litters)
|
||||
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
|
||||
const rows = page.locator('.litter-row, .gerbil-card, li a').filter({ hasText: /Wurf/i })
|
||||
await expect(rows.first()).toBeVisible()
|
||||
expect(await rows.count()).toBeGreaterThanOrEqual(20)
|
||||
})
|
||||
|
||||
test('Live-Data: Wurf-Detail mit beiden Eltern rendert "Elterntiere"', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Direkt zu einem Wurf mit beiden Eltern navigieren (2026-02-10)
|
||||
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
|
||||
// Elterntiere-Section immer sichtbar
|
||||
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Vater-Link zu einem Tier
|
||||
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('Live-Data: Wurf-Detail ohne Eltern rendert "Elterntiere" als "unbekannt"', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
// Wurf A (2010-02-18) hat weder Vater noch Mutter
|
||||
await page.goto('/wuerfe/34852596-2bd7-48ad-9175-4223d3d99229')
|
||||
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// "unbekannt" Platzhalter soll sichtbar sein (kein Link)
|
||||
await expect(page.getByText(tl.detail.unknownParent ?? 'unbekannt')).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 5. BECKEN / KONTAKTE (leere Zustände) ────────────────────────────────────
|
||||
|
||||
test('Live-Data: Becken-Seite zeigt Leer-Zustand (keine Becken importiert)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.enclosures)
|
||||
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Leer-Meldung oder "Neues Becken"-Button
|
||||
const emptyOrNew = page.locator('*').filter({ hasText: de.pages.becken.empty }).or(
|
||||
page.getByRole('link', { name: de.pages.becken.newButton })
|
||||
)
|
||||
await expect(emptyOrNew.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Live-Data: Kontakte-Seite zeigt Leer-Zustand (keine Kontakte importiert)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.contacts)
|
||||
await expect(page.getByRole('heading', { name: de.pages.kontakte.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.getByText(de.pages.kontakte.empty)).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 6. GENETIK / PROBEVERPAARUNG ─────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Genetik-Seite lädt mit echten Genotypen', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.genetics)
|
||||
await expect(page.getByRole('heading', { name: de.pages.genetik.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Probeverpaarung-UI sichtbar
|
||||
await expect(page.getByText(de.pages.genetik.subtitle)).toBeVisible()
|
||||
// Tier-Auswahl Vater/Mutter sichtbar
|
||||
await expect(page.locator('select, input[type="search"], [placeholder]').first()).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 7. STATISTIK ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Statistik-Seite rendert Charts ohne Crash', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.statistics)
|
||||
await expect(page.getByRole('heading', { name: de.pages.statistik.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Mindestens ein Chart-Canvas oder SVG (865 Würfe = Charts vorhanden)
|
||||
const chart = page.locator('canvas, svg.bar-chart, svg.line-chart, .chart-wrapper, .bar-chart, .line-chart')
|
||||
await expect(chart.first()).toBeVisible({ timeout: 12_000 })
|
||||
})
|
||||
|
||||
// ── 8. EINSTELLUNGEN ─────────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Einstellungen-Seite lädt Züchterprofil', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.settings)
|
||||
await expect(page.getByRole('heading', { name: de.pages.einstellungen.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.getByText(de.pages.einstellungen.zuchtprofil.title)).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 9. WEBSEITE (CMS) ────────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Webseite-Seite lädt ohne Crash', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.website)
|
||||
await expect(page.getByRole('heading', { name: de.pages.webseite.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Seitenliste mit Seeds vorhanden (8 Seiten aus dem Seed)
|
||||
await expect(page.locator('.gerbil-card, li').first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ── 10. ANFRAGEN ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Anfragen-Seite zeigt Leer-Zustand (0 Anfragen)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.requests)
|
||||
await expect(page.getByRole('heading', { name: de.pages.anfragen.title, exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Leer-Text soll sichtbar sein (keine Anfragen live)
|
||||
await expect(page.getByText(de.pages.anfragen.empty)).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 11. KONSOLEN-FEHLER-MONITOR ───────────────────────────────────────────────
|
||||
|
||||
test('Live-Data: Keine unhandled-rejection-Fehler beim Durchlauf aller Hauptseiten', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`)
|
||||
})
|
||||
|
||||
// Navigiere direkt per URL statt gotoSection — gotoSection benutzt getByRole('navigation')
|
||||
// welches bei Seiten mit Pager-Navigation (>1 Page) in strict-mode fehlschlägt (Infra-Bug).
|
||||
for (const path of ['/', '/rennmaeuse', '/statistik', '/genetik', '/einstellungen']) {
|
||||
await page.goto(path)
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
// Keine unkritischen Errors (ignoriere CORS-Warnungen, die im Dev-Modus normal sind)
|
||||
const criticalErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes('CORS') &&
|
||||
!e.includes('favicon') &&
|
||||
!e.includes('DevTools') &&
|
||||
!e.includes('Warning:'),
|
||||
)
|
||||
expect(criticalErrors).toHaveLength(0)
|
||||
})
|
||||
|
||||
// ── 12. DESKTOP-OVERFLOW-CHECK ───────────────────────────────────────────────
|
||||
// (dieses Projekt-File läuft in beiden Viewports dank playwright.config.ts)
|
||||
|
||||
test('Live-Data: Rennmäuse-Liste kein horizontal Overflow (Desktop 1280px)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
await expect(page.locator('.gerbil-row').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
}))
|
||||
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 2)
|
||||
})
|
||||
@@ -40,18 +40,13 @@ test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)',
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
// Nach Re-Import #2 hat mindestens Gerbil C (Victoria) beide Eltern.
|
||||
// Der Wurf-Detail ist die einfachste Ansicht, die Eltern-Links rendert.
|
||||
await gotoSection(page, de.nav.litters)
|
||||
await expect(page.getByRole('heading', { name: tl.title, exact: true })).toBeVisible()
|
||||
// Direkt zu einem Wurf mit beiden Eltern navigieren (Re-Import #2.5, stabil).
|
||||
// Wurf (aus Diagramm) 2026-02-10 — fatherId + motherId beide gesetzt.
|
||||
// Kein nth()-Selektor mehr: bei 865 Würfen + Pager-Nav nicht deterministisch.
|
||||
await page.goto('/wuerfe/a1db02cf-48ff-4f3a-8eab-33f2360c27f0')
|
||||
|
||||
// Mindestens 1 Wurf in der Liste; ersten öffnen.
|
||||
const firstLink = page.getByRole('link').filter({ hasText: /.+/ }).nth(1)
|
||||
await expect(firstLink).toBeVisible()
|
||||
await firstLink.click()
|
||||
|
||||
// Detailseite: Elterntier-Abschnitt ist sichtbar
|
||||
await expect(page.getByText(tl.detail.parents)).toBeVisible()
|
||||
// Detailseite: Elterntier-Abschnitt immer sichtbar (kein bedingtes Rendering)
|
||||
await expect(page.getByText(tl.detail.parents, { exact: true })).toBeVisible({ timeout: 8_000 })
|
||||
// Mindestens ein Link führt zu einem Tier — bei fehlendem Import fehlt dieser Link
|
||||
await expect(page.locator('a[href*="/rennmaeuse/"]').first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
@@ -149,6 +149,8 @@ export function seedDb(): MockDb {
|
||||
enclosureId: 'enc-leer',
|
||||
notes: 'neugieriger Entdecker',
|
||||
},
|
||||
// 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[] = [
|
||||
|
||||
@@ -53,3 +53,12 @@ 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('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)
|
||||
})
|
||||
|
||||
@@ -98,6 +98,48 @@ test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', as
|
||||
await expect(page.getByText(/85\s*g/)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
// Krümel hat litterId 'w-kruemel' → Name 'Wurf K' → Link /wuerfe/w-kruemel
|
||||
const litterLink = page.getByRole('link', { name: 'Wurf K' })
|
||||
await expect(litterLink).toBeVisible()
|
||||
await litterLink.click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
|
||||
// Kein Link mit diesem Namen — es ist reiner Text
|
||||
await expect(page.getByRole('link', { name: 'Zoohandlung Meier' })).toBeHidden()
|
||||
})
|
||||
|
||||
test('Detailseite: Herkunft zeigt Kontaktlink wenn originContactId gesetzt (WURF-LINK-Addendum)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hat originContactId='con-meier' → Link /kontakte/con-meier (Priorität vor originBreeder)
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
const originLink = page.getByRole('link', { name: 'Zoohandlung Meier' })
|
||||
await expect(originLink).toBeVisible()
|
||||
await expect(originLink).toHaveAttribute('href', '/kontakte/con-meier')
|
||||
})
|
||||
|
||||
test('Namenlose Tiere zeigen Platzhalter in der Liste (UI-POLISH-1)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse')
|
||||
// 'nameless-stub' hat name='' → Platzhalter '(ohne Namen)' soll als Link sichtbar sein
|
||||
await expect(page.getByRole('link', { name: de.pages.gerbils.nameless })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Namenlose Tiere zeigen Platzhalter im Detail-Header (UI-POLISH-1)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/nameless-stub')
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.nameless })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
@@ -107,3 +149,26 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier bearbeiten — originBreeder Freitext round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin hat originBreeder='Zoohandlung Meier', kein originContactId → Freitext-Feld vorbelegt
|
||||
await page.goto('/rennmaeuse/fridolin/bearbeiten')
|
||||
await expect(page.getByLabel(t.fields.originBreeder)).toHaveValue('Zoohandlung Meier')
|
||||
await page.getByLabel(t.fields.originBreeder).fill('Neue Musterzucht')
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
// Detailseite: Herkunft zeigt den neuen Wert (kein Contact → originBreeder-Text)
|
||||
await expect(page.getByText('Neue Musterzucht')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
// Standard: Unbekannt (null → value='')
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('')
|
||||
await page.getByLabel(t.fields.isDeaf).selectOption({ label: t.form.isDeafYes })
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
// Zurück zur Bearbeiten-Seite: Wert muss erhalten sein
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||
})
|
||||
|
||||
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",
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface Gerbil {
|
||||
* as resident (backend default true).
|
||||
*/
|
||||
isResident?: boolean
|
||||
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||
isDeaf?: boolean | null
|
||||
}
|
||||
|
||||
/** Payload for POST /gerbils. */
|
||||
@@ -76,6 +78,7 @@ export interface CreateGerbil {
|
||||
characterTraits?: string[] | null
|
||||
characterNote?: string | null
|
||||
isResident?: boolean
|
||||
isDeaf?: boolean | null
|
||||
}
|
||||
|
||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
|
||||
const nameless = de.pages.gerbils.nameless
|
||||
import { listGerbils } from '../api/gerbils'
|
||||
import { andFilter, condition } from '../api/gridify'
|
||||
import type { Gender, Gerbil, Paged } from '../api/types'
|
||||
@@ -62,7 +64,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
{g.name || nameless}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -222,8 +222,9 @@ describe('Farbschlag catalog', () => {
|
||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||
// Every row has a non-empty canonical genotype display string and unique name.
|
||||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||
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[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
})
|
||||
|
||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||
@@ -523,3 +524,99 @@ 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-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 rere',
|
||||
)
|
||||
// C-locus het: cchm + ch
|
||||
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
||||
)
|
||||
// C-locus hom cchm
|
||||
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
||||
)
|
||||
})
|
||||
|
||||
// ── 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 rere')
|
||||
})
|
||||
|
||||
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||
'aa CC DD Ee GG PP spsp rere',
|
||||
)
|
||||
})
|
||||
|
||||
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||
'aa CC DD Ee[f] GG PP spsp rere',
|
||||
)
|
||||
})
|
||||
|
||||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||||
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
||||
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 rere', () => {
|
||||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['cchm', 'ch'])
|
||||
expect(g.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
})
|
||||
|
||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
||||
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
expect(g.D).toEqual(['d', 'd'])
|
||||
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 rere')
|
||||
})
|
||||
|
||||
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 rere')
|
||||
})
|
||||
})
|
||||
|
||||
406
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
406
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
@@ -0,0 +1,406 @@
|
||||
[
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"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": "dd Gold",
|
||||
"english": "dd Argente Golden",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 15,
|
||||
"image": "gold-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "dd 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": "Agouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||
"sortOrder": 28,
|
||||
"image": "agouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberagouti dd",
|
||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 29,
|
||||
"image": "silberagouti-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||
"sortOrder": 30,
|
||||
"image": "kohlfuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Anthrazit dd",
|
||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||
"sortOrder": 31,
|
||||
"image": "anthrazit-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "kohlfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "goldfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Gold-Hell",
|
||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "gold-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 41,
|
||||
"image": "blaufuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "polarfuchs-hell.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchs, hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||
"sortOrder": 45,
|
||||
"image": "rotfuchs-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchs-Hell",
|
||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "kohlfuchs-hell-2.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchs, hell",
|
||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||
"sortOrder": 47,
|
||||
"image": "algierfuchs-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Topas dd",
|
||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "topas-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchs dd",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"sortOrder": 49,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"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",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
}
|
||||
]
|
||||
@@ -2,35 +2,35 @@
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
||||
"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 rere",
|
||||
"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 rere",
|
||||
"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 rere",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
@@ -210,31 +210,31 @@
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -270,7 +270,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -282,7 +282,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -318,89 +318,89 @@
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||
"sortOrder": 61
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] 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[chm] DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
}
|
||||
]
|
||||
|
||||
@@ -74,6 +74,32 @@ export function wildType(): Genotype {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 rere".
|
||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
|
||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||
* convention) — e.g. ['C','?'] renders "C-".
|
||||
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
||||
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
||||
*/
|
||||
export function toDisplayString(g: Genotype): string {
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== '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 +178,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
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function GerbilDetailPage() {
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<GerbilProfilePhoto gerbilId={g.id} />
|
||||
<h2>{g.name}</h2>
|
||||
<h2>{g.name || de.pages.gerbils.nameless}</h2>
|
||||
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||
@@ -153,7 +153,7 @@ export default function GerbilDetailPage() {
|
||||
{g.status === 'Deceased' && (
|
||||
<>
|
||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath} />
|
||||
<Row label={t.fields.causeOfDeath} value={g.causeOfDeath || null} />
|
||||
</>
|
||||
)}
|
||||
{g.status === 'GivenAway' && (
|
||||
@@ -173,10 +173,24 @@ export default function GerbilDetailPage() {
|
||||
}
|
||||
/>
|
||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
||||
<Row label={t.fields.litter} value={lookup(litterName, g.litterId)} />
|
||||
<Row label={t.fields.origin} value={lookup(contactName, g.originContactId)} />
|
||||
<Row
|
||||
label={t.fields.litter}
|
||||
value={
|
||||
g.litterId && litterName.has(g.litterId)
|
||||
? <Link to={`/wuerfe/${g.litterId}`}>{litterName.get(g.litterId)}</Link>
|
||||
: lookup(litterName, g.litterId)
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label={t.fields.origin}
|
||||
value={
|
||||
g.originContactId && contactName.has(g.originContactId)
|
||||
? <Link to={`/kontakte/${g.originContactId}`}>{contactName.get(g.originContactId)}</Link>
|
||||
: (g.originBreeder || null)
|
||||
}
|
||||
/>
|
||||
<Row label={t.fields.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||
<Row label={t.fields.notes} value={g.notes} />
|
||||
<Row label={t.fields.notes} value={g.notes || null} />
|
||||
</dl>
|
||||
|
||||
<h3>{t.detail.genetics}</h3>
|
||||
|
||||
@@ -21,10 +21,12 @@ interface FormState {
|
||||
enclosureId: string
|
||||
litterId: string
|
||||
originContactId: string
|
||||
originBreeder: string
|
||||
receiverContactId: string
|
||||
genotype: string
|
||||
notes: string
|
||||
isResident: boolean
|
||||
isDeaf: boolean | null
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -39,10 +41,12 @@ const EMPTY: FormState = {
|
||||
enclosureId: '',
|
||||
litterId: '',
|
||||
originContactId: '',
|
||||
originBreeder: '',
|
||||
receiverContactId: '',
|
||||
genotype: '',
|
||||
notes: '',
|
||||
isResident: true,
|
||||
isDeaf: null,
|
||||
}
|
||||
|
||||
function formFromGerbil(g: {
|
||||
@@ -57,10 +61,12 @@ function formFromGerbil(g: {
|
||||
enclosureId: string | null
|
||||
litterId: string | null
|
||||
originContactId: string | null
|
||||
originBreeder?: string | null
|
||||
receiverContactId: string | null
|
||||
genotype: string | null
|
||||
notes: string | null
|
||||
isResident?: boolean | null
|
||||
isDeaf?: boolean | null
|
||||
}): FormState {
|
||||
return {
|
||||
name: g.name,
|
||||
@@ -74,10 +80,12 @@ function formFromGerbil(g: {
|
||||
enclosureId: g.enclosureId ?? '',
|
||||
litterId: g.litterId ?? '',
|
||||
originContactId: g.originContactId ?? '',
|
||||
originBreeder: g.originBreeder ?? '',
|
||||
receiverContactId: g.receiverContactId ?? '',
|
||||
genotype: g.genotype ?? '',
|
||||
notes: g.notes ?? '',
|
||||
isResident: g.isResident ?? true,
|
||||
isDeaf: g.isDeaf ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,10 +175,12 @@ export default function GerbilFormPage() {
|
||||
enclosureId: nn(form.enclosureId),
|
||||
litterId: nn(form.litterId),
|
||||
originContactId: nn(form.originContactId),
|
||||
originBreeder: nn(form.originBreeder),
|
||||
receiverContactId: nn(form.receiverContactId),
|
||||
genotype: nn(form.genotype),
|
||||
notes: nn(form.notes),
|
||||
isResident: form.isResident,
|
||||
isDeaf: form.isDeaf,
|
||||
}
|
||||
const result = await mutation.run(body)
|
||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||
@@ -326,6 +336,16 @@ export default function GerbilFormPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.originBreeder}</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.originBreeder}
|
||||
placeholder="z. B. Zucht der Kleinen Chaoten"
|
||||
onChange={(e) => set('originBreeder', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.receiver}</span>
|
||||
<select
|
||||
@@ -360,6 +380,21 @@ export default function GerbilFormPage() {
|
||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>{t.fields.isDeaf}</span>
|
||||
<select
|
||||
value={form.isDeaf === null ? '' : String(form.isDeaf)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
set('isDeaf', v === '' ? null : v === 'true')
|
||||
}}
|
||||
>
|
||||
<option value="">{t.form.isDeafUnknown}</option>
|
||||
<option value="true">{t.form.isDeafYes}</option>
|
||||
<option value="false">{t.form.isDeafNo}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="field field--check" title={t.form.isResidentHint}>
|
||||
<span>{t.form.isResidentLabel}</span>
|
||||
<input
|
||||
|
||||
@@ -256,11 +256,11 @@ export default function GerbilsPage() {
|
||||
className="gerbil-row__check"
|
||||
checked={selected.has(g.id)}
|
||||
onChange={() => toggleSelect(g.id)}
|
||||
aria-label={g.name}
|
||||
aria-label={g.name || t.nameless}
|
||||
/>
|
||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||
<span className="gerbil-card__name">
|
||||
{g.name}
|
||||
{g.name || t.nameless}
|
||||
{g.isResident === false && (
|
||||
<span className="badge badge--external" title={t.externalTitle}>
|
||||
{t.externalBadge}
|
||||
|
||||
@@ -276,7 +276,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}
|
||||
@@ -377,7 +377,7 @@ function PedigreeCard({
|
||||
<div className="pedigree-card__body">
|
||||
<div className="pedigree-card__name">
|
||||
<SexIcon gender={g.gender} />
|
||||
<span className="pedigree-card__nametext">{g.name}</span>
|
||||
<span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
|
||||
</div>
|
||||
{farbschlag && (
|
||||
<span
|
||||
@@ -439,7 +439,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 +494,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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,9 +71,9 @@ 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) : '',
|
||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||
notes: l.notes ?? '',
|
||||
@@ -178,7 +178,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 +199,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>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const de = {
|
||||
pages: {
|
||||
gerbils: {
|
||||
title: 'Rennmäuse',
|
||||
nameless: '(ohne Namen)',
|
||||
// Liste
|
||||
newButton: 'Neue Rennmaus',
|
||||
empty: 'Keine Rennmäuse gefunden.',
|
||||
@@ -79,6 +80,8 @@ export const de = {
|
||||
enclosure: 'Gehege',
|
||||
colorVariety: 'Farbschlag',
|
||||
origin: 'Herkunft',
|
||||
originBreeder: 'Herkunft (Freitext)',
|
||||
isDeaf: 'Gehörlos',
|
||||
receiver: 'Abnehmer',
|
||||
genotype: 'Genotyp',
|
||||
notes: 'Notizen',
|
||||
@@ -113,6 +116,9 @@ export const de = {
|
||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||
isDeafUnknown: 'Unbekannt',
|
||||
isDeafYes: 'Ja',
|
||||
isDeafNo: 'Nein',
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
saving: 'Speichern …',
|
||||
|
||||
@@ -129,7 +129,9 @@ def norm_zucht(z):
|
||||
if not z:
|
||||
return ""
|
||||
n = z.lower()
|
||||
n = re.sub(r"\bv\.\s?d\.\b", " ", n)
|
||||
# TOLERANT KC-MATCHER FIX: trailing \b after '.' never fires when next char is ' '
|
||||
# (both are non-word chars), so "v.d. kleinen" was NOT stripped. Drop the trailing \b.
|
||||
n = re.sub(r"\bv\.\s?d\.", " ", n)
|
||||
n = re.sub(r"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
||||
n = re.sub(r"[^a-z0-9äöüß ]", " ", n)
|
||||
words = [w[:-1] if len(w) > 4 and w.endswith("n") else w for w in n.split()]
|
||||
@@ -137,6 +139,15 @@ def norm_zucht(z):
|
||||
return ZUCHT_ALIASES.get(key, key)
|
||||
|
||||
|
||||
def is_clan_zucht(z):
|
||||
"""True if the zucht name (raw or canonical) identifies the Kleine Chaoten home cattery.
|
||||
|
||||
Accepts all known spellings: 'Zucht der Kleinen Chaoten', 'kleinen Chaoten',
|
||||
'v.d. Kleinen Chaoten', '[ZdkC]', 'kleinechaote', etc.
|
||||
"""
|
||||
return norm_zucht(z) == "kleinechaote"
|
||||
|
||||
|
||||
def canon_pair(raw):
|
||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||
name, zucht = split_name_zucht(raw)
|
||||
@@ -962,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path):
|
||||
continue
|
||||
a["resolvedByDecision"] = True
|
||||
if d.get("genotype"):
|
||||
a["genotype"] = gt.parse(d["genotype"])
|
||||
# CR-10: validate the parsed genotype — a typo'd decision string yields empty
|
||||
# mapped8locus and would silently blank the animal's genotype while marking it
|
||||
# 'resolved'. Only apply if the parse produces non-empty loci.
|
||||
parsed = gt.parse(d["genotype"])
|
||||
if parsed.get("mapped8locus"):
|
||||
a["genotype"] = parsed
|
||||
else:
|
||||
# Keep the existing genotype; flag as a warning in the report.
|
||||
a.setdefault("decisionWarnings", []).append(
|
||||
f"Ungültiger Override-Genotyp '{d['genotype']}' — "
|
||||
"konnte nicht geparst werden (mapped8locus leer). "
|
||||
"Bestehender Genotyp behalten; Konflikt wurde trotzdem aufgelöst."
|
||||
)
|
||||
if d.get("farbschlag"):
|
||||
a["farbschlag"] = d["farbschlag"]
|
||||
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||
|
||||
@@ -280,6 +280,63 @@ check("gen.+v.d. name rejected", e.looks_like_animal_name("Victoria Welby gen. W
|
||||
check("real Farbschlag accepted", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
||||
|
||||
# --- CR-10: malformed decision genotype must NOT blank the existing genotype ---
|
||||
dec_cr10 = os.path.join(tempfile.gettempdir(), "decisions-cr10.json")
|
||||
_json.dump({"resolutions": [
|
||||
# Valid decision (genotype parses OK) -> should be applied
|
||||
{"name": "Agouti OK", "dob": "01.01.2020", "decision": "test",
|
||||
"genotype": "aa CC DD ee GG PP spsp rere", "source": "test"},
|
||||
# Malformed genotype (typo'd) -> must NOT blank genotype; conflict still resolved
|
||||
{"name": "Siamese Bad", "dob": "02.02.2020", "decision": "test",
|
||||
"genotype": "BLÖDSINN!!!", "source": "test"},
|
||||
]}, open(dec_cr10, "w", encoding="utf-8"))
|
||||
merged_cr10 = [
|
||||
{"id": "g1", "name": "Agouti OK", "dob": "01.01.2020", "conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"A": ["a","a"]}, "rawGenotype": "aa", "unmappedTokens": []}},
|
||||
{"id": "g2", "name": "Siamese Bad", "dob": "02.02.2020", "conflict": True, "farbschlag": "", "death": "",
|
||||
"genotype": {"mapped8locus": {"C": ["c^h","c^h"]}, "rawGenotype": "chmchm", "unmappedTokens": []}},
|
||||
]
|
||||
conflicts_cr10 = [{"id": "g1"}, {"id": "g2"}]
|
||||
n_cr10 = e.apply_conflict_decisions(merged_cr10, conflicts_cr10, dec_cr10)
|
||||
check("CR-10: valid decision genotype is applied (A-locus updated)",
|
||||
merged_cr10[0]["genotype"]["mapped8locus"].get("C") == ["C","C"])
|
||||
check("CR-10: malformed decision genotype NOT applied (C-locus preserved)",
|
||||
merged_cr10[1]["genotype"]["mapped8locus"].get("C") == ["c^h","c^h"])
|
||||
check("CR-10: malformed decision still un-quarantines the animal",
|
||||
merged_cr10[1].get("conflict") is False)
|
||||
check("CR-10: malformed decision adds a decisionWarning",
|
||||
bool(merged_cr10[1].get("decisionWarnings")))
|
||||
check("CR-10: apply returns correct resolved count (2 conflicts cleared)", n_cr10 == 2)
|
||||
try: os.remove(dec_cr10)
|
||||
except OSError: pass
|
||||
|
||||
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
||||
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
||||
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
||||
# "v.d. kleinen chaoten" was NOT stripped before. Fix: drop the trailing \b.
|
||||
check("KC-matcher: 'Zucht der Kleinen Chaoten'",
|
||||
e.is_clan_zucht("Zucht der Kleinen Chaoten"))
|
||||
check("KC-matcher: 'kleinen Chaoten' (no prefix)",
|
||||
e.is_clan_zucht("kleinen Chaoten"))
|
||||
check("KC-matcher: 'v.d. Kleinen Chaoten' (v.d. prefix — was broken before fix)",
|
||||
e.is_clan_zucht("v.d. Kleinen Chaoten"))
|
||||
check("KC-matcher: '[ZdkC]' shorthand (bracket form, alias in ZUCHT_ALIASES)",
|
||||
e.is_clan_zucht("ZdkC"))
|
||||
check("KC-matcher: 'von den Kleinen Chaoten' (full long form)",
|
||||
e.is_clan_zucht("von den Kleinen Chaoten"))
|
||||
check("KC-matcher: empty string -> NOT clan",
|
||||
not e.is_clan_zucht(""))
|
||||
check("KC-matcher: 'Black Forest' -> NOT clan",
|
||||
not e.is_clan_zucht("Black Forest"))
|
||||
check("KC-matcher: norm_zucht regression — 'Kleine Chaoten' (base form still works)",
|
||||
e.norm_zucht("Kleine Chaoten") == "kleinechaote")
|
||||
check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
|
||||
e.norm_zucht("von den Kleinen Chaoten") == "kleinechaote")
|
||||
# Decision-matching FIX-1 already tested above; v.d. in decision matches 'von den' in record
|
||||
# because both reduce to the same canon_pair. Verify norm_zucht directly for v.d.:
|
||||
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
|
||||
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
|
||||
|
||||
if failed:
|
||||
print(f"\n{failed} test(s) FAILED")
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user