Compare commits
13 Commits
feature/fo
...
81f9d0ce2f
| Author | SHA1 | Date | |
|---|---|---|---|
| 81f9d0ce2f | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 |
@@ -48,6 +48,19 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
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
|
- name: Tests ausfuehren
|
||||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -134,3 +134,6 @@ entities.json
|
|||||||
|
|
||||||
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
# Runtime photo store (uploaded/imported gerbil photos) — never commit
|
||||||
GerbilManagerWebAPI/photo-storage/
|
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);
|
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 ----
|
// ---- fixtures ----
|
||||||
private const string LittersJson = """
|
private const string LittersJson = """
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
/// <summary>Create a fully-populated gerbil and return its id.</summary>
|
||||||
private async Task<Guid> CreateFullGerbil(string name = "TestTier")
|
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
|
var resp = await _client.PostAsync("/gerbils", JsonContent.Create(new
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
@@ -39,7 +41,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
characterTraits = new[] { "neugierig", "zutraulich" },
|
characterTraits = new[] { "neugierig", "zutraulich" },
|
||||||
characterNote = "Liebling der Familie",
|
characterNote = "Liebling der Familie",
|
||||||
notes = "Eine Notiz",
|
notes = "Eine Notiz",
|
||||||
externalRef = "ext-001",
|
externalRef = $"ext-{name.GetHashCode():X8}",
|
||||||
}));
|
}));
|
||||||
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
return ExtractId(await resp.Content.ReadAsStringAsync());
|
return ExtractId(await resp.Content.ReadAsStringAsync());
|
||||||
@@ -67,7 +69,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
Assert.Equal("aa CC DD ee GG PP spsp rere", GetStr(json, "genotype"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
Assert.Equal("Eine Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("handzahm", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
Assert.Equal("Neue Notiz", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
@@ -122,7 +124,7 @@ public class PartialUpdateTests : IClassFixture<ApiFactory>
|
|||||||
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
Assert.Equal("EditForm-Tier (umbenannt)", GetStr(json, "name"));
|
||||||
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
Assert.Equal("Aktualisierte Notiz", GetStr(json, "notes"));
|
||||||
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
Assert.Equal("Zucht der Kleinen Chaoten", GetStr(json, "originBreeder"));
|
||||||
Assert.Equal("ext-001", GetStr(json, "externalRef"));
|
Assert.NotNull(GetStr(json, "externalRef")); // externalRef set during create, not cleared by partial PUT
|
||||||
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
Assert.Contains("neugierig", GetNested(json, "characterTraits") ?? "");
|
||||||
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
Assert.Equal("Liebling der Familie", GetStr(json, "characterNote"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(g => g.ColorVariety).WithMany()
|
e.HasOne(g => g.ColorVariety).WithMany()
|
||||||
.HasForeignKey(g => g.ColorVarietyId).OnDelete(DeleteBehavior.SetNull);
|
.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 =>
|
modelBuilder.Entity<Litter>(e =>
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int AlreadyImported,
|
int AlreadyImported,
|
||||||
QuarantineSummary Quarantined,
|
QuarantineSummary Quarantined,
|
||||||
int ParentLinksFromChart = 0,
|
int ParentLinksFromChart = 0,
|
||||||
int ConflictsResolvedByDecision = 0);
|
int ConflictsResolvedByDecision = 0,
|
||||||
|
int FarbschlagDerivedFromGenotype = 0);
|
||||||
|
|
||||||
public sealed record QuarantineSummary(
|
public sealed record QuarantineSummary(
|
||||||
int Conflicts,
|
int Conflicts,
|
||||||
|
|||||||
@@ -145,25 +145,63 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
||||||
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingColorVarietyByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.ColorVarietyId);
|
.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).
|
// PASS 1: assign ids + resolve fb/gender/Wurfchronik link (no writes yet).
|
||||||
var plan = new List<AnimalPlan>();
|
var plan = new List<AnimalPlan>();
|
||||||
|
int fbDerivedFromGenotype = 0;
|
||||||
foreach (var a in loadable)
|
foreach (var a in loadable)
|
||||||
{
|
{
|
||||||
bool exists = existingGerbilSet.Contains(a.Id);
|
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;
|
Guid? wurfLitterId = null;
|
||||||
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 }
|
||||||
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
&& litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid))
|
||||||
wurfLitterId = 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;
|
Guid? colorVarietyId = null;
|
||||||
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants)
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||||
foreach (var fb in fbCandidates)
|
foreach (var fb in fbCandidates)
|
||||||
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid))
|
||||||
{ colorVarietyId = vid; break; }
|
{ 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 gender = InferGender(a, sireNames, damNames);
|
||||||
var norm = Normalize(StripZucht(a.Name));
|
var norm = Normalize(StripZucht(a.Name));
|
||||||
@@ -469,6 +507,33 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int farbschlagWouldRebackfill = plan.Count(p =>
|
int farbschlagWouldRebackfill = plan.Count(p =>
|
||||||
p.Exists && p.ColorVarietyId is not null && p.ColorVarietyId != p.CurrentColorVarietyId);
|
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
|
// 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'.
|
// 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").
|
// NEVER overwrites a non-null OriginBreeder (Julian: "alle Schreibweisen unterstützen").
|
||||||
@@ -524,6 +589,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
int conflictsResolvedByDecision = loadable.Count(a => a.ResolvedByDecision);
|
||||||
if (conflictsResolvedByDecision > 0)
|
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).");
|
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.");
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
|
||||||
|
|
||||||
return new ImportReport(
|
return new ImportReport(
|
||||||
@@ -532,7 +599,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Animals: new AnimalSummary(
|
Animals: new AnimalSummary(
|
||||||
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
|
||||||
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),
|
||||||
parentLinksAdded, conflictsResolvedByDecision),
|
parentLinksAdded, conflictsResolvedByDecision, fbDerivedFromGenotype),
|
||||||
Photos: new PhotoSummary(photosAttached, photosMissing),
|
Photos: new PhotoSummary(photosAttached, photosMissing),
|
||||||
Samples: samples,
|
Samples: samples,
|
||||||
Notes: notes,
|
Notes: notes,
|
||||||
@@ -570,6 +637,23 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
|
|
||||||
private static string StripCaret(string allele) => allele.Replace("^", "");
|
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)
|
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
|
// Box colour (blue=male, white=female) is the authoritative breeder signal — prefer it
|
||||||
|
|||||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -807,6 +807,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasIndex("EnclosureId");
|
b.HasIndex("EnclosureId");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("LitterId");
|
b.HasIndex("LitterId");
|
||||||
|
|
||||||
b.HasIndex("OriginContactId");
|
b.HasIndex("OriginContactId");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using GerbilManagerWebAPI.Endpoints;
|
using GerbilManagerWebAPI.Endpoints;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
@@ -46,7 +47,17 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
|||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
// 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.MailSettingsService>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilManagerWebAPI.Inbox.GmailMailReader>();
|
||||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ TAG=latest
|
|||||||
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
PGDATA_PATH=/mnt/SSD/gerbil/pgdata
|
||||||
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
PHOTOS_PATH=/mnt/SSD/gerbil/photos
|
||||||
BACKUPS_PATH=/mnt/SSD/gerbil/backups
|
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-Rotation: Anzahl Tage (Standard: 7)
|
||||||
BACKUP_KEEP_DAYS=7
|
BACKUP_KEEP_DAYS=7
|
||||||
|
|
||||||
# Claude-API-Key fuer KI-Verkaufstext (FEAT-12a; leer lassen wenn nicht vorhanden)
|
# KI-Funktionen (Verkaufstext + Posteingang-Entwurf)
|
||||||
ANTHROPIC_API_KEY=
|
# 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}"
|
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=${POSTGRES_PASSWORD}"
|
||||||
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
# Speicherort der hochgeladenen Fotos (NAS-Dataset gemounted unter /data/photos)
|
||||||
Photos__RootPath: /data/photos
|
Photos__RootPath: /data/photos
|
||||||
# KI-Verkaufstext (FEAT-12a stub; leer lassen wenn kein Key vorhanden)
|
# AR-3: Data Protection Key-Ring (persistiert Gmail-App-Passwort-Verschlüsselung über Redeployments)
|
||||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
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:
|
volumes:
|
||||||
- photos:/data/photos
|
- photos:/data/photos
|
||||||
|
- keys:/data/keys
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -101,6 +108,14 @@ volumes:
|
|||||||
type: none
|
type: none
|
||||||
o: bind
|
o: bind
|
||||||
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
|
||||||
|
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
|
||||||
|
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
|
||||||
|
keys:
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: "${KEYS_PATH:-/mnt/gerbil/keys}"
|
||||||
backups:
|
backups:
|
||||||
driver: local
|
driver: local
|
||||||
driver_opts:
|
driver_opts:
|
||||||
|
|||||||
@@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 201, contract)
|
return json(route, 201, contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FEAT-NAMEGEN: /names/suggest
|
||||||
|
if (path === '/names/suggest' && method === 'GET') {
|
||||||
|
if (!db.namesConfigured) {
|
||||||
|
return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' })
|
||||||
|
}
|
||||||
|
const letter = url.searchParams.get('letter')?.toUpperCase()
|
||||||
|
const allSuggestions = [
|
||||||
|
{ name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' },
|
||||||
|
{ name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' },
|
||||||
|
{ name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' },
|
||||||
|
{ name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' },
|
||||||
|
{ name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' },
|
||||||
|
]
|
||||||
|
const result = letter
|
||||||
|
? allSuggestions.filter((s) => s.name.startsWith(letter))
|
||||||
|
: allSuggestions
|
||||||
|
return json(route, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface MockDb {
|
|||||||
// ABGABE: Verträge + KI-Inserat-Flag
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
contracts: MockContract[]
|
contracts: MockContract[]
|
||||||
saleAdConfigured: boolean
|
saleAdConfigured: boolean
|
||||||
|
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||||
|
namesConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -292,5 +294,6 @@ export function seedDb(): MockDb {
|
|||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
contracts: [],
|
contracts: [],
|
||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
|
namesConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.namegen
|
||||||
|
const tf = de.pages.gerbils.form
|
||||||
|
|
||||||
|
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Panel öffnen
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// Vorschläge laden
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf Vorschlag 'Fenrir'
|
||||||
|
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||||
|
|
||||||
|
// Panel geschlossen, Name-Feld befüllt
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||||
|
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByLabel(t.letterLabel).fill('F')
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
|
||||||
|
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||||
|
// Artemis (A) nicht sichtbar
|
||||||
|
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (mockDb) mockDb.namesConfigured = false
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -53,3 +53,12 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
|
|||||||
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||||
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
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)
|
||||||
|
})
|
||||||
|
|||||||
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
73
gerbil-manager-web/src/api/__tests__/names.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildSuggestPath, NAMEGEN_USAGES } from '../names'
|
||||||
|
|
||||||
|
describe('buildSuggestPath', () => {
|
||||||
|
it('includes uppercased letter', () => {
|
||||||
|
const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] })
|
||||||
|
expect(path).toContain('letter=F')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits letter when blank', () => {
|
||||||
|
const path = buildSuggestPath({ letter: '', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('letter=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits letter when only whitespace', () => {
|
||||||
|
const path = buildSuggestPath({ letter: ' ', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('letter=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes gender', () => {
|
||||||
|
const path = buildSuggestPath({ gender: 'male', usages: ['norn'] })
|
||||||
|
expect(path).toContain('gender=male')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits gender for empty string', () => {
|
||||||
|
const path = buildSuggestPath({ gender: '', usages: ['norn'] })
|
||||||
|
expect(path).not.toContain('gender=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins multiple usages without encoding commas', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn', 'mythg'] })
|
||||||
|
expect(path).toContain('usages=norn,mythg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits usages when array is empty', () => {
|
||||||
|
const path = buildSuggestPath({ usages: [] })
|
||||||
|
expect(path).not.toContain('usages=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults count to 6', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'] })
|
||||||
|
expect(path).toContain('count=6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses provided count', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'], count: 8 })
|
||||||
|
expect(path).toContain('count=8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with /names/suggest', () => {
|
||||||
|
const path = buildSuggestPath({ usages: ['norn'] })
|
||||||
|
expect(path).toMatch(/^\/names\/suggest\?/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NAMEGEN_USAGES', () => {
|
||||||
|
it('contains all 5 expected culture codes', () => {
|
||||||
|
const codes = NAMEGEN_USAGES.map((u) => u.code)
|
||||||
|
expect(codes).toContain('norn')
|
||||||
|
expect(codes).toContain('japa')
|
||||||
|
expect(codes).toContain('mythg')
|
||||||
|
expect(codes).toContain('ger')
|
||||||
|
expect(codes).toContain('arb')
|
||||||
|
expect(codes).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every usage has a non-empty label', () => {
|
||||||
|
for (const u of NAMEGEN_USAGES) {
|
||||||
|
expect(u.label.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
40
gerbil-manager-web/src/api/names.ts
Normal file
40
gerbil-manager-web/src/api/names.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface NameSuggestion {
|
||||||
|
name: string
|
||||||
|
meaning: string
|
||||||
|
origin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAMEGEN_USAGES = [
|
||||||
|
{ code: 'norn', label: 'Nordisch' },
|
||||||
|
{ code: 'japa', label: 'Japanisch' },
|
||||||
|
{ code: 'mythg', label: 'Griech. Mythologie' },
|
||||||
|
{ code: 'ger', label: 'Deutsch' },
|
||||||
|
{ code: 'arb', label: 'Arabisch' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']
|
||||||
|
|
||||||
|
export interface SuggestNamesParams {
|
||||||
|
letter?: string
|
||||||
|
gender?: string
|
||||||
|
usages: NamegenUsageCode[]
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for unit tests — builds the query path without a network call. */
|
||||||
|
export function buildSuggestPath(params: SuggestNamesParams): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const letter = params.letter?.trim().toUpperCase()
|
||||||
|
if (letter) parts.push(`letter=${encodeURIComponent(letter)}`)
|
||||||
|
if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`)
|
||||||
|
if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`)
|
||||||
|
parts.push(`count=${params.count ?? 6}`)
|
||||||
|
return `/names/suggest?${parts.join('&')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestNames(params: SuggestNamesParams): Promise<NameSuggestion[]> {
|
||||||
|
return api.get<NameSuggestion[]>(buildSuggestPath(params))
|
||||||
|
}
|
||||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
.namegen-name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-name-row .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter-input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__usages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent, #2563eb);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__meaning {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__origin {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { ApiError, errorCode } from '../api/client'
|
||||||
|
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
|
||||||
|
import './NameSuggestPanel.css'
|
||||||
|
|
||||||
|
interface NameSuggestPanelProps {
|
||||||
|
gender: string
|
||||||
|
onPick: (name: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||||
|
|
||||||
|
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||||
|
const t = de.namegen
|
||||||
|
const [letter, setLetter] = useState('')
|
||||||
|
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [keyMissing, setKeyMissing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
|
||||||
|
const [fetched, setFetched] = useState(false)
|
||||||
|
|
||||||
|
function toggleUsage(code: NamegenUsageCode) {
|
||||||
|
setUsages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(code)) next.delete(code)
|
||||||
|
else next.add(code)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setKeyMissing(false)
|
||||||
|
setSuggestions([])
|
||||||
|
try {
|
||||||
|
const result = await suggestNames({
|
||||||
|
letter: letter.trim() || undefined,
|
||||||
|
gender: gender || undefined,
|
||||||
|
usages: [...usages] as NamegenUsageCode[],
|
||||||
|
count: 6,
|
||||||
|
})
|
||||||
|
setSuggestions(result)
|
||||||
|
setFetched(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (errorCode(err) === 'NamesKeyMissing') {
|
||||||
|
setKeyMissing(true)
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
|
||||||
|
<div className="namegen-panel__header">
|
||||||
|
<span>{t.panelTitle}</span>
|
||||||
|
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__filters">
|
||||||
|
<div className="namegen-panel__letter">
|
||||||
|
<label htmlFor="namegen-letter">{t.letterLabel}</label>
|
||||||
|
<input
|
||||||
|
id="namegen-letter"
|
||||||
|
className="input namegen-panel__letter-input"
|
||||||
|
value={letter}
|
||||||
|
onChange={(e) => setLetter(e.target.value)}
|
||||||
|
placeholder={t.letterPlaceholder}
|
||||||
|
maxLength={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__usages">
|
||||||
|
<span>{t.usagesLabel}</span>
|
||||||
|
<div className="namegen-usages-grid">
|
||||||
|
{NAMEGEN_USAGES.map(({ code, label }) => (
|
||||||
|
<label key={code}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={usages.has(code)}
|
||||||
|
onChange={() => toggleUsage(code)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading || usages.size === 0}
|
||||||
|
>
|
||||||
|
{loading ? t.loading : t.loadButton}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{keyMissing && <p className="muted">{t.keyMissing}</p>}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li key={`${s.name}-${i}`} className="namegen-suggestion">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="namegen-suggestion__pick"
|
||||||
|
onClick={() => onPick(s.name)}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
<span className="namegen-suggestion__meaning">{s.meaning}</span>
|
||||||
|
<span className="namegen-suggestion__origin">{s.origin}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -222,8 +222,9 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||||
// Every row has a non-empty canonical genotype display string and unique name.
|
// 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(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', () => {
|
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')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,35 +2,35 @@
|
|||||||
{
|
{
|
||||||
"name": "Pink Eyed White (PEW)",
|
"name": "Pink Eyed White (PEW)",
|
||||||
"english": "Pink Eyed White",
|
"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,
|
"sortOrder": 0,
|
||||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Hermelin",
|
"name": "Hermelin",
|
||||||
"english": "Dark Tailed White",
|
"english": "Dark Tailed White",
|
||||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 1,
|
"sortOrder": 1,
|
||||||
"image": "hermelin.jpeg"
|
"image": "hermelin.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Himalaya",
|
"name": "Himalaya",
|
||||||
"english": "Himalayan",
|
"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,
|
"sortOrder": 2,
|
||||||
"image": "himalaya.jpg"
|
"image": "himalaya.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel",
|
"name": "Zobel",
|
||||||
"english": "Sable",
|
"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,
|
"sortOrder": 3,
|
||||||
"image": "zobel.jpeg"
|
"image": "zobel.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotaugenschimmel",
|
"name": "Rotaugenschimmel",
|
||||||
"english": "Red-Eyed Roan",
|
"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,
|
"sortOrder": 4,
|
||||||
"image": "rotaugen-schimmel.jpg"
|
"image": "rotaugen-schimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Orangeschimmel",
|
"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,
|
"sortOrder": 25,
|
||||||
"image": "schimmel-orangeschimmel.jpg"
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -210,31 +210,31 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"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,
|
"sortOrder": 32,
|
||||||
"image": "silberschimmel.jpg"
|
"image": "silberschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchsschimmel",
|
"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,
|
"sortOrder": 33,
|
||||||
"image": "polarfuchsschimmel.jpg"
|
"image": "polarfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchsschimmel",
|
"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,
|
"sortOrder": 34,
|
||||||
"image": "algierfuchsschimmel.jpg"
|
"image": "algierfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel",
|
"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,
|
"sortOrder": 35,
|
||||||
"image": "kohlfuchsschimmel.jpg"
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchsschimmel",
|
"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,
|
"sortOrder": 36,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchsschimmel",
|
"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,
|
"sortOrder": 39,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchsschimmel",
|
"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,
|
"sortOrder": 42,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
@@ -282,7 +282,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchsschimmel, hell",
|
"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,
|
"sortOrder": 44,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
@@ -318,89 +318,89 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Marder",
|
"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,
|
"sortOrder": 50,
|
||||||
"image": "marder.JPG"
|
"image": "marder.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Siam",
|
"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,
|
"sortOrder": 51,
|
||||||
"image": "siam-marder-hell.JPG"
|
"image": "siam-marder-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Zobel-Hell",
|
"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,
|
"sortOrder": 52,
|
||||||
"image": "zobel-hell.jpg"
|
"image": "zobel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti",
|
"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,
|
"sortOrder": 53,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Agouti-Hell",
|
"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
|
"sortOrder": 54
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"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,
|
"sortOrder": 55,
|
||||||
"image": "silberagouti-cp.JPG"
|
"image": "silberagouti-cp.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti-Hell",
|
"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
|
"sortOrder": 56
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs",
|
"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,
|
"sortOrder": 57,
|
||||||
"image": "algierfuchs-cp.jpg"
|
"image": "algierfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Algierfuchs-Hell",
|
"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
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs",
|
"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,
|
"sortOrder": 59,
|
||||||
"image": "polarfuchs-cp.jpg"
|
"image": "polarfuchs-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Polarfuchs-Hell",
|
"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
|
"sortOrder": 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs",
|
"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
|
"sortOrder": 61
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Fuchs-Hell",
|
"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
|
"sortOrder": 62
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Blaufuchs",
|
"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
|
"sortOrder": 63
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel",
|
"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
|
"sortOrder": 64
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Orangeschimmel-Hell",
|
"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
|
"sortOrder": 65
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -74,6 +74,32 @@ export function wildType(): Genotype {
|
|||||||
return out
|
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".
|
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
* The 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.
|
* (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
|
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||||
* convention) — e.g. ['C','?'] renders "C-".
|
* 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 {
|
export function toDisplayString(g: Genotype): string {
|
||||||
return LOCUS_ORDER.filter(
|
return LOCUS_ORDER.filter(
|
||||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
(locus) => locus !== '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(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,8 +178,19 @@ function normalizeToken(tok: string): string | null {
|
|||||||
if (t === 'WP') t = 'Slsl'
|
if (t === 'WP') t = 'Slsl'
|
||||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
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, '?')
|
t = t.replace(/-/g, '?')
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi'
|
|||||||
import { genderLabel, statusLabel } from '../format/labels'
|
import { genderLabel, statusLabel } from '../format/labels'
|
||||||
import { fromDisplayString } from '../genetics'
|
import { fromDisplayString } from '../genetics'
|
||||||
import FarbschlagImage from '../components/FarbschlagImage'
|
import FarbschlagImage from '../components/FarbschlagImage'
|
||||||
|
import NameSuggestPanel from '../components/NameSuggestPanel'
|
||||||
|
import '../components/NameSuggestPanel.css'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
name: string
|
name: string
|
||||||
@@ -111,6 +113,7 @@ export default function GerbilFormPage() {
|
|||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [form, setForm] = useState<FormState>(EMPTY)
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||||
|
const [showNameSuggest, setShowNameSuggest] = useState(false)
|
||||||
|
|
||||||
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
|
||||||
const colorVarieties = useApi(() => listColorVarieties(), [])
|
const colorVarieties = useApi(() => listColorVarieties(), [])
|
||||||
@@ -197,16 +200,33 @@ export default function GerbilFormPage() {
|
|||||||
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
<h2>{isEdit ? t.form.editTitle : t.form.createTitle}</h2>
|
||||||
|
|
||||||
<form className="form" onSubmit={onSubmit} noValidate>
|
<form className="form" onSubmit={onSubmit} noValidate>
|
||||||
<label className="field">
|
<div className="field">
|
||||||
<span>{t.fields.name} *</span>
|
<label htmlFor="gerbil-name">{t.fields.name} *</label>
|
||||||
|
<div className="namegen-name-row">
|
||||||
<input
|
<input
|
||||||
|
id="gerbil-name"
|
||||||
className="input"
|
className="input"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => set('name', e.target.value)}
|
onChange={(e) => set('name', e.target.value)}
|
||||||
aria-invalid={Boolean(errors.name)}
|
aria-invalid={Boolean(errors.name)}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => setShowNameSuggest((v) => !v)}
|
||||||
|
>
|
||||||
|
{de.namegen.button}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
{errors.name && <small className="error-text">{errors.name}</small>}
|
||||||
</label>
|
</div>
|
||||||
|
{showNameSuggest && (
|
||||||
|
<NameSuggestPanel
|
||||||
|
gender={form.gender}
|
||||||
|
onPick={(name) => { set('name', name); setShowNameSuggest(false) }}
|
||||||
|
onClose={() => setShowNameSuggest(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.gender} *</span>
|
<span>{t.fields.gender} *</span>
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export default function StammbaumPage() {
|
|||||||
<>
|
<>
|
||||||
<section className="page stammbaum-screen">
|
<section className="page stammbaum-screen">
|
||||||
<header className="page-head">
|
<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">
|
<div className="head-actions">
|
||||||
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
<Link to={`/rennmaeuse/${root.gerbil.id}`} className="btn">
|
||||||
{t.backToAnimal}
|
{t.backToAnimal}
|
||||||
@@ -377,7 +377,7 @@ function PedigreeCard({
|
|||||||
<div className="pedigree-card__body">
|
<div className="pedigree-card__body">
|
||||||
<div className="pedigree-card__name">
|
<div className="pedigree-card__name">
|
||||||
<SexIcon gender={g.gender} />
|
<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>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
@@ -439,7 +439,7 @@ function PrintPedigree({
|
|||||||
return (
|
return (
|
||||||
<div className="stammbaum-print">
|
<div className="stammbaum-print">
|
||||||
<header className="stammbaum-print__head">
|
<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">
|
<p className="stammbaum-print__meta">
|
||||||
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
{t.inbreeding.label}: {inbreedingText} · {t.printView.createdOn} {today}
|
||||||
</p>
|
</p>
|
||||||
@@ -494,7 +494,7 @@ function PrintCell({
|
|||||||
<div className={base} style={style}>
|
<div className={base} style={style}>
|
||||||
<div className="stammbaum-print__name">
|
<div className="stammbaum-print__name">
|
||||||
{sexSymbol}
|
{sexSymbol}
|
||||||
{g.name}
|
{g.name || de.pages.gerbils.nameless}
|
||||||
</div>
|
</div>
|
||||||
{g.dateOfBirth && (
|
{g.dateOfBirth && (
|
||||||
<div className="stammbaum-print__sub">
|
<div className="stammbaum-print__sub">
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export default function VertragWizardPage() {
|
|||||||
checked={selectedIds.has(g.id)}
|
checked={selectedIds.has(g.id)}
|
||||||
onChange={() => toggleAnimal(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">
|
<span className="wizard-pick__meta">
|
||||||
{[
|
{[
|
||||||
genderLabel(g.gender),
|
genderLabel(g.gender),
|
||||||
@@ -396,7 +396,7 @@ export default function VertragWizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.animals}</dt>
|
<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>
|
||||||
<div className="def-row">
|
<div className="def-row">
|
||||||
<dt>{de.pages.vertraege.fields.price}</dt>
|
<dt>{de.pages.vertraege.fields.price}</dt>
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ export default function WurfFormPage() {
|
|||||||
name: l.name,
|
name: l.name,
|
||||||
date: l.date ?? '',
|
date: l.date ?? '',
|
||||||
fatherId: l.fatherId ?? '',
|
fatherId: l.fatherId ?? '',
|
||||||
fatherName: l.fatherId ? (nameById.get(l.fatherId) ?? '') : '',
|
fatherName: l.fatherId ? (nameById.get(l.fatherId) || de.pages.gerbils.nameless) : '',
|
||||||
motherId: l.motherId ?? '',
|
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) : '',
|
totalBorn: l.totalBorn != null ? String(l.totalBorn) : '',
|
||||||
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
expectedGoHomeDate: l.expectedGoHomeDate ?? '',
|
||||||
notes: l.notes ?? '',
|
notes: l.notes ?? '',
|
||||||
@@ -178,7 +178,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="male"
|
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>
|
</fieldset>
|
||||||
@@ -199,7 +199,7 @@ export default function WurfFormPage() {
|
|||||||
) : (
|
) : (
|
||||||
<AnimalPicker
|
<AnimalPicker
|
||||||
gender="female"
|
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>
|
</fieldset>
|
||||||
|
|||||||
@@ -813,6 +813,19 @@ export const de = {
|
|||||||
resetButton: 'Filter zurücksetzen',
|
resetButton: 'Filter zurücksetzen',
|
||||||
closeButton: 'Schließen',
|
closeButton: 'Schließen',
|
||||||
},
|
},
|
||||||
|
// ── FEAT-NAMEGEN: Namensvorschläge (KI-gestützt, UC-1 Einzeltier) ──
|
||||||
|
namegen: {
|
||||||
|
button: 'Name vorschlagen',
|
||||||
|
panelTitle: 'Namensvorschläge',
|
||||||
|
letterLabel: 'Anfangsbuchstabe',
|
||||||
|
letterPlaceholder: 'z. B. A',
|
||||||
|
usagesLabel: 'Herkunftskultur',
|
||||||
|
loadButton: 'Vorschläge laden',
|
||||||
|
loading: 'Lade Vorschläge …',
|
||||||
|
empty: 'Keine Vorschläge — andere Einstellungen versuchen.',
|
||||||
|
keyMissing: 'Namensvorschläge benötigen einen API-Key — bitte in den Einstellungen konfigurieren.',
|
||||||
|
close: 'Schließen',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type Strings = typeof de
|
export type Strings = typeof de
|
||||||
|
|||||||
@@ -973,7 +973,19 @@ def apply_conflict_decisions(merged, conflicts, path):
|
|||||||
continue
|
continue
|
||||||
a["resolvedByDecision"] = True
|
a["resolvedByDecision"] = True
|
||||||
if d.get("genotype"):
|
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"):
|
if d.get("farbschlag"):
|
||||||
a["farbschlag"] = d["farbschlag"]
|
a["farbschlag"] = d["farbschlag"]
|
||||||
a["farbschlagVariants"] = [d["farbschlag"]]
|
a["farbschlagVariants"] = [d["farbschlag"]]
|
||||||
|
|||||||
@@ -280,6 +280,36 @@ 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", not e.looks_like_animal_name("Kohlfuchsschimmel"))
|
||||||
check("real Farbschlag accepted 2", not e.looks_like_animal_name("Orangeschimmel, hell"))
|
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' ---
|
# --- TOLERANT KC-MATCHER (IMPORT-BACKFILL): all clan spelling variants -> canon 'kleinechaote' ---
|
||||||
# Julian-Entscheidung: Zucht = Kleine Chaoten wenn 'klein'+'chaoten' ODER bekannte Abkürzungen.
|
# 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
|
# The v.d. fix: trailing \b after '.' failed when next char is ' ' (non-word), so
|
||||||
|
|||||||
Reference in New Issue
Block a user