Compare commits
101 Commits
feature/we
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| a42966ad8e | |||
| 76e635a122 | |||
| 00886b1c6e | |||
| d5e8946d7d | |||
| e367657254 | |||
| 8c43a8dc03 | |||
| 08732d8348 | |||
| c6edb2e9d0 | |||
| 8e475dff6a | |||
| 54ffb809c4 | |||
| 4403574f3d | |||
| bcdea8011b | |||
| 643f1a6831 | |||
| 27a3eb802c | |||
| 13a887608c | |||
| f74af537e2 | |||
| bb832b7f95 | |||
| 3a75005f43 | |||
| f2a64ad66b | |||
| 3706ba3ecd | |||
| c8ab6bf072 | |||
| 30ee7b6198 | |||
| 29207e41da | |||
| 7fe538dd5d | |||
| ff42341c47 | |||
| 90f998d189 | |||
| d5f4be76be | |||
| 433f9ee592 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| 92dd614269 | |||
| 097a04cbd8 | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a | |||
| 865b3831c8 | |||
| 1b0d3286db | |||
| de1d9460b3 | |||
| 259dd96c0e | |||
| 615abfa510 | |||
| 69ffe58d6b | |||
| d7c0698c44 | |||
| db49150206 | |||
| f6a4025d71 | |||
| 8175b150f7 | |||
| 319aca7608 | |||
| 4d9e901139 | |||
| 01781f23d6 | |||
| 55c298ad1f | |||
| e1e088f041 | |||
| 358e30db1f | |||
| 0b9b03eb2d | |||
| d4c9be23c8 | |||
| f6bfc0ebab | |||
| 3160b66697 | |||
| 39767cef0f | |||
| 162a0fafb1 | |||
| c26ac5c6dd | |||
| 2bbdfe2b71 | |||
| 2c3f342905 | |||
| b165916dfb | |||
| 797b38df34 | |||
| c34d63e0fd | |||
| a2bdf9626c | |||
| 0d4b560df8 | |||
| d116b8d8f1 | |||
| 8dfad32918 | |||
| 1ee176a24a | |||
| a2f5e848d1 | |||
| de293904bb | |||
| 68447896ee | |||
| 5f26594a12 | |||
| 6eb82da90c | |||
| 0a1e63bec6 | |||
| 5b30407257 | |||
| c357a18418 | |||
| 791e9f91cc | |||
| efce79b3fa | |||
| 868c5f5cd7 | |||
| ddfa3614a4 | |||
| 13da8f2502 | |||
| 13eb17b453 |
@@ -9,10 +9,10 @@
|
|||||||
# 2. npm test + npm run build (Frontend)
|
# 2. npm test + npm run build (Frontend)
|
||||||
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
# 3. Docker-Images bauen und in die Gitea-Registry pushen
|
||||||
#
|
#
|
||||||
# Registry: truenas:13000 (internes Gitea Container Registry)
|
# Registry: 192.168.2.115:13000 (internes Gitea Container Registry)
|
||||||
# Images:
|
# Images:
|
||||||
# truenas:13000/gulum/gerbilmanager-api:latest
|
# 192.168.2.115:13000/gulum/gerbilmanager-api:latest
|
||||||
# truenas:13000/gulum/gerbilmanager-frontend:latest
|
# 192.168.2.115:13000/gulum/gerbilmanager-frontend:latest
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: truenas:13000
|
REGISTRY: 192.168.2.115:13000
|
||||||
REGISTRY_OWNER: gulum
|
REGISTRY_OWNER: gulum
|
||||||
DOTNET_VERSION: "10.0.x"
|
DOTNET_VERSION: "10.0.x"
|
||||||
NODE_VERSION: "22"
|
NODE_VERSION: "22"
|
||||||
@@ -48,6 +48,26 @@ 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
|
||||||
|
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||||
|
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||||
|
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||||
|
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||||
|
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||||
|
run: |
|
||||||
|
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||||
|
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: DB-3 EF Migrations Drift-Check
|
||||||
|
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||||
|
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||||
|
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||||
|
run: >
|
||||||
|
dotnet ef migrations has-pending-model-changes
|
||||||
|
--project GerbilManagerWebAPI
|
||||||
|
--startup-project GerbilManagerWebAPI
|
||||||
|
--no-build --configuration Release
|
||||||
|
|
||||||
- name: Tests ausfuehren
|
- 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/
|
||||||
|
|||||||
276
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
276
GerbilManager.Tests/ImportDocxServiceTests.cs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d-CREATE / FIX-8D-DEDUP: docx importer CREATE path tests.
|
||||||
|
/// Uses SQLite (not InMemory) so FK + unique-index constraints are enforced —
|
||||||
|
/// this is the only reliable way to catch duplicate-ExternalRef crashes.
|
||||||
|
/// </summary>
|
||||||
|
public class ImportDocxServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
|
||||||
|
public ImportDocxServiceTests()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(Path.GetTempPath(), "feat8d-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One open connection keeps the :memory: SQLite DB alive across calls.
|
||||||
|
private (ApplicationContext db, SqliteConnection conn) NewSqliteDb()
|
||||||
|
{
|
||||||
|
var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated(); // schema WITH unique index on ExternalRef
|
||||||
|
return (db, conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteLitters(object litters) =>
|
||||||
|
File.WriteAllText(Path.Combine(_dir, "docx_litters.json"),
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(litters));
|
||||||
|
|
||||||
|
private void WriteAnimals(object animals) =>
|
||||||
|
File.WriteAllText(Path.Combine(_dir, "docx_animals.json"),
|
||||||
|
System.Text.Json.JsonSerializer.Serialize(animals));
|
||||||
|
|
||||||
|
// ── Test 1: dry-run shows correct counts without writing ─────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DryRun_counts_new_animal_without_writing()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
// Litter in DB — date-only lookup (±5 days, exactly one candidate)
|
||||||
|
var litter = new Litter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Testwurf",
|
||||||
|
Date = new DateOnly(2023, 5, 1),
|
||||||
|
};
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||||
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: false);
|
||||||
|
|
||||||
|
Assert.False(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, report.LitterLinked); // date-only match finds the one litter
|
||||||
|
Assert.Equal(1, report.GoHomeFilled);
|
||||||
|
Assert.Equal(0, await db.Gerbils.CountAsync()); // nothing written
|
||||||
|
Assert.Equal(0, await db.Contacts.CountAsync()); // nothing written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 2: execute creates animal with all fields + FK constraints ───────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_creates_animal_with_litter_contact_and_goHomeDate()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
var litter = new Litter
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Testwurf",
|
||||||
|
Date = new DateOnly(2023, 5, 1),
|
||||||
|
};
|
||||||
|
db.Litters.Add(litter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "01.05.2023", name = "Pepper", gender = "female",
|
||||||
|
owner = "Max Mustermann", abgabeDate = "01.07.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.True(report.Executed);
|
||||||
|
Assert.Equal(1, report.Created);
|
||||||
|
Assert.Equal(1, report.ContactsCreated);
|
||||||
|
|
||||||
|
var gerbil = await db.Gerbils.SingleAsync();
|
||||||
|
Assert.Equal("Pepper", gerbil.Name);
|
||||||
|
Assert.Equal(new DateOnly(2023, 5, 1), gerbil.DateOfBirth);
|
||||||
|
Assert.Equal(Gender.female, gerbil.Gender);
|
||||||
|
Assert.Equal(GerbilStatus.GivenAway, gerbil.Status);
|
||||||
|
Assert.Equal(litter.Id, gerbil.LitterId); // date-only link worked
|
||||||
|
Assert.Equal(new DateOnly(2023, 7, 1), gerbil.GoHomeDate);
|
||||||
|
Assert.Equal("Zucht der Kleinen Chaoten", gerbil.OriginBreeder);
|
||||||
|
Assert.False(gerbil.IsResident);
|
||||||
|
Assert.Equal("docx", gerbil.ImportSource);
|
||||||
|
Assert.StartsWith("docx-", gerbil.ExternalRef);
|
||||||
|
|
||||||
|
var contact = await db.Contacts.SingleAsync();
|
||||||
|
Assert.Equal("Max Mustermann", contact.Name);
|
||||||
|
Assert.Equal(contact.Id, gerbil.ReceiverContactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 3: idempotency — second run creates zero ────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_is_idempotent_second_run_creates_zero()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "2/3", litterDob = "15.03.2023", name = "Flash", gender = "male",
|
||||||
|
owner = "", abgabeDate = "01.05.2023",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var first = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(1, first.Created);
|
||||||
|
|
||||||
|
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Created); // ExternalRef path, no dupe
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // exactly one row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 4: existing main-import animal → enrich only, no duplicate ──────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_enriches_existing_animal_does_not_duplicate()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
// Animal already in DB (from main import, ExternalRef ≠ "docx-…")
|
||||||
|
var existing = new Gerbil
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Eddie",
|
||||||
|
DateOfBirth = new DateOnly(2022, 8, 10),
|
||||||
|
Gender = Gender.male,
|
||||||
|
ExternalRef = "main-eddie-xyz", // NOT a "docx-" ref
|
||||||
|
IsResident = false,
|
||||||
|
};
|
||||||
|
db.Gerbils.Add(existing);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
// Same name+dob → enrich, not create
|
||||||
|
new { wsCode = "", litterDob = "10.08.2022", name = "Eddie", gender = "male",
|
||||||
|
owner = "Anna Beispiel", abgabeDate = "01.10.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(0, report.Created);
|
||||||
|
Assert.Equal(1, await db.Gerbils.CountAsync()); // still exactly one
|
||||||
|
|
||||||
|
// GoHomeDate was enriched
|
||||||
|
var updated = await db.Gerbils.SingleAsync();
|
||||||
|
Assert.Equal(new DateOnly(2022, 10, 1), updated.GoHomeDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 5: Deceased status when deathDate set ───────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_sets_Deceased_status_when_deathDate_provided()
|
||||||
|
{
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "1/5", litterDob = "01.01.2022", name = "Ghost", gender = "male",
|
||||||
|
owner = "", abgabeDate = "",
|
||||||
|
deathDate = "15.06.2022", deathCause = "Tumor", farbschlag = "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
var gerbil = await db.Gerbils.SingleAsync();
|
||||||
|
Assert.Equal(GerbilStatus.Deceased, gerbil.Status);
|
||||||
|
Assert.Equal(new DateOnly(2022, 6, 15), gerbil.DateOfDeath);
|
||||||
|
Assert.Equal("Tumor", gerbil.CauseOfDeath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test 6: P0 REGRESSION — same-name siblings get distinct ExternalRefs ─
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_same_name_siblings_created_with_distinct_ExternalRefs()
|
||||||
|
{
|
||||||
|
// P0 regression: two animals in the same litter with the same normalized name
|
||||||
|
// previously caused duplicate ExternalRef → Npgsql/SQLite 23505 unique-key crash.
|
||||||
|
// Fix: ordinal disambiguation (-2) ensures uniqueness within the batch.
|
||||||
|
// The unique index on IX_Gerbils_ExternalRef (via EnsureCreated on SQLite) makes
|
||||||
|
// this test an authoritative regression gate.
|
||||||
|
var (db, conn) = NewSqliteDb();
|
||||||
|
await using (conn)
|
||||||
|
await using (db)
|
||||||
|
{
|
||||||
|
WriteLitters(Array.Empty<object>());
|
||||||
|
WriteAnimals(new[]
|
||||||
|
{
|
||||||
|
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||||
|
owner = "Eva Müller", abgabeDate = "10.08.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" },
|
||||||
|
// Identical name+wsCode+litterDob → base ExternalRef collision
|
||||||
|
new { wsCode = "4/4", litterDob = "10.06.2022", name = "Mochi", gender = "female",
|
||||||
|
owner = "Lena Braun", abgabeDate = "11.08.2022",
|
||||||
|
deathDate = "", deathCause = "", farbschlag = "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must NOT throw unique-key violation
|
||||||
|
var report = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, report.Created);
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
|
||||||
|
var refs = (await db.Gerbils.Select(g => g.ExternalRef!).ToListAsync()).OrderBy(r => r).ToList();
|
||||||
|
// Both start with the docx- prefix
|
||||||
|
Assert.All(refs, r => Assert.StartsWith("docx-", r));
|
||||||
|
// Must be distinct (unique index enforces this in SQLite)
|
||||||
|
Assert.Equal(2, refs.Distinct().Count());
|
||||||
|
// Second occurrence gets the -2 suffix
|
||||||
|
Assert.Single(refs, r => r.EndsWith("-2"));
|
||||||
|
|
||||||
|
// Idempotent re-run: zero new, still 2 in DB
|
||||||
|
var second = await new ImportDocxService(db, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Created);
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(2, await db.Litters.CountAsync());
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||||
|
{
|
||||||
|
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||||
|
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||||
|
using var db = NewDb();
|
||||||
|
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
// ExternalRef is set on created litters
|
||||||
|
var litters = await db.Litters.ToListAsync();
|
||||||
|
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||||
|
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||||
|
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||||
|
|
||||||
|
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||||
|
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||||
|
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||||
|
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, second.Litters.Created);
|
||||||
|
Assert.Equal(2, await db.Litters.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||||
{
|
{
|
||||||
@@ -236,6 +261,54 @@ namespace GerbilManager.Tests
|
|||||||
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Execute_circular_gerbil_litter_dependency_does_not_throw()
|
||||||
|
{
|
||||||
|
// Regression for "Unable to save changes because a circular dependency was detected":
|
||||||
|
// Gerbil [Added] ← FK{MotherId} Litter [Added] ← FK{LitterId} Gerbil [Added].
|
||||||
|
// Triggered when a gerbil is both parent (in one synth litter) and offspring
|
||||||
|
// (LitterId → another synth litter) in the SAME SaveChanges batch — forms a cycle EF
|
||||||
|
// topo-sort cannot resolve. This test encodes the minimal reproducer: A's mother is B,
|
||||||
|
// B's mother is A (artificial genealogical cycle, but triggers the EF cycle reliably).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "cycle-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
using var conn = new SqliteConnection("DataSource=:memory:");
|
||||||
|
conn.Open();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), """
|
||||||
|
[
|
||||||
|
{"id":"alpha","name":"Alpha","dob":"01.01.2020","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[{"name":"Beta","dob":"01.01.2019","roleGuess":"mother","method":"chart-position","confidence":"medium"}]},
|
||||||
|
{"id":"beta","name":"Beta","dob":"01.01.2019","death":"","farbschlag":"",
|
||||||
|
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
|
||||||
|
"parentRefs":[{"name":"Alpha","dob":"01.01.2020","roleGuess":"mother","method":"chart-position","confidence":"medium"}]}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>().UseSqlite(conn).Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
// must NOT throw InvalidOperationException (circular dependency)
|
||||||
|
var report = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||||
|
var alpha = await db.Gerbils.SingleAsync(g => g.ExternalRef == "alpha");
|
||||||
|
var beta = await db.Gerbils.SingleAsync(g => g.ExternalRef == "beta");
|
||||||
|
// both should be litter-linked
|
||||||
|
Assert.NotNull(alpha.LitterId);
|
||||||
|
Assert.NotNull(beta.LitterId);
|
||||||
|
// deferred FK update must have set the litter parents correctly
|
||||||
|
var alphaLitter = await db.Litters.SingleAsync(l => l.Id == alpha.LitterId);
|
||||||
|
Assert.Equal(beta.Id, alphaLitter.MotherId);
|
||||||
|
var betaLitter = await db.Litters.SingleAsync(l => l.Id == beta.LitterId);
|
||||||
|
Assert.Equal(alpha.Id, betaLitter.MotherId);
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
public async Task Derived_litter_with_quarantined_parent_leaves_FK_null_no_throw()
|
||||||
{
|
{
|
||||||
@@ -507,6 +580,75 @@ namespace GerbilManager.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
|
||||||
|
{
|
||||||
|
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
|
||||||
|
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// One dated litter, one undated litter (blank date field)
|
||||||
|
File.WriteAllText(Path.Combine(dir, "litters.json"), """
|
||||||
|
[
|
||||||
|
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
|
||||||
|
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
|
||||||
|
]
|
||||||
|
""");
|
||||||
|
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
|
||||||
|
|
||||||
|
using var db = NewDb();
|
||||||
|
|
||||||
|
// First run
|
||||||
|
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(2, r1.Litters.InSource);
|
||||||
|
Assert.Equal(1, r1.Litters.Created); // only the dated one
|
||||||
|
Assert.Equal(0, r1.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
|
||||||
|
|
||||||
|
// Second run (re-import): dated litter is now existing, undated still WithoutDate
|
||||||
|
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
|
||||||
|
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
|
||||||
|
Assert.Equal(1, r2.Litters.AlreadyImported);
|
||||||
|
Assert.Equal(1, r2.Litters.WithoutDate);
|
||||||
|
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
|
||||||
|
}
|
||||||
|
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeedGen3g_existing_varieties_preserve_id_name_binding()
|
||||||
|
{
|
||||||
|
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be
|
||||||
|
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and
|
||||||
|
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact.
|
||||||
|
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed
|
||||||
|
|
||||||
|
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries)
|
||||||
|
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
|
||||||
|
var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
|
||||||
|
Assert.NotNull(cpFuchs);
|
||||||
|
Assert.Equal("CP-Fuchs", cpFuchs!.Name);
|
||||||
|
|
||||||
|
// Existing CP-*-Hell entries (IDs 62-66) unchanged
|
||||||
|
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name);
|
||||||
|
Assert.Equal("CP-Silberagouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000063"))!.Name);
|
||||||
|
Assert.Equal("CP-Algierfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000064"))!.Name);
|
||||||
|
Assert.Equal("CP-Polarfuchs-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000065"))!.Name);
|
||||||
|
Assert.Equal("CP-Orangeschimmel-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000066"))!.Name);
|
||||||
|
|
||||||
|
// 4 new Dilute Fuchs varieties appended (IDs 67-70)
|
||||||
|
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name);
|
||||||
|
Assert.Equal("Dilute Goldfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000068"))!.Name);
|
||||||
|
Assert.Equal("Dilute Rotfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000069"))!.Name);
|
||||||
|
Assert.Equal("Dilute Polarfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000070"))!.Name);
|
||||||
|
|
||||||
|
// Total count is exactly 70
|
||||||
|
Assert.Equal(70, db.ColorVarieties.Count());
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("01.02.2020", 2020, 2, 1)]
|
[InlineData("01.02.2020", 2020, 2, 1)]
|
||||||
[InlineData("5.3.21", 2021, 3, 5)]
|
[InlineData("5.3.21", 2021, 3, 5)]
|
||||||
@@ -516,6 +658,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 = """
|
||||||
[
|
[
|
||||||
|
|||||||
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Names;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||||
|
/// fence strip), 503-not-configured path, upstream-error path.
|
||||||
|
/// </summary>
|
||||||
|
public class NameSuggestionTests
|
||||||
|
{
|
||||||
|
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||||
|
Assert.Contains("reinen JSON-Array", prompt);
|
||||||
|
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||||
|
Assert.Contains("name", prompt);
|
||||||
|
Assert.Contains("meaning", prompt);
|
||||||
|
Assert.Contains("origin", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||||
|
Assert.Contains("6", prompt);
|
||||||
|
Assert.Contains("\"A\"", prompt);
|
||||||
|
Assert.Contains("weibliche", prompt);
|
||||||
|
Assert.Contains("norn,mythg", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||||
|
Assert.Contains("5", prompt);
|
||||||
|
Assert.DoesNotContain("Buchstaben", prompt);
|
||||||
|
Assert.DoesNotContain("weibliche", prompt);
|
||||||
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||||
|
{
|
||||||
|
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||||
|
Assert.DoesNotContain("weibliche", prompt);
|
||||||
|
Assert.DoesNotContain("männliche", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||||
|
{
|
||||||
|
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(json);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("Astrid", result[0].Name);
|
||||||
|
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||||
|
Assert.Equal("Altnordisch", result[0].Origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Aiko", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Luna", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||||
|
{
|
||||||
|
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||||
|
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Sol", result![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||||
|
{
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||||
|
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, "key", "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", null, "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", "key", null)]
|
||||||
|
[InlineData(null, null, null)]
|
||||||
|
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||||
|
string? baseUrl, string? apiKey, string? model)
|
||||||
|
{
|
||||||
|
var service = CreateService(baseUrl, apiKey, model,
|
||||||
|
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 5);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||||
|
Assert.Null(result.Suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||||
|
{
|
||||||
|
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||||
|
var handler = new StubHandler(_ => Canned(payload));
|
||||||
|
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.NotNull(result.Suggestions);
|
||||||
|
Assert.Equal(2, result.Suggestions!.Count);
|
||||||
|
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||||
|
{
|
||||||
|
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||||
|
var handler = new StubHandler(_ => Canned(fenced));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 1);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||||
|
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 3);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||||
|
Assert.Null(result.Suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.SuggestAsync(null, null, null, 3);
|
||||||
|
|
||||||
|
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helfer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static NameSuggestionService CreateService(
|
||||||
|
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||||
|
{
|
||||||
|
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||||
|
return new NameSuggestionService(new HttpClient(handler), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Canned(string content)
|
||||||
|
{
|
||||||
|
var completion = new
|
||||||
|
{
|
||||||
|
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||||
|
};
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(respond(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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") ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,9 +67,9 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||||
Assert.Contains("NIEMALS Preise", prompt);
|
Assert.Contains("NIEMALS Preise", prompt);
|
||||||
Assert.Contains("KEINE Fakten erfinden", prompt);
|
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||||
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
|
// few-shot: verbatim listings from kleine-chaoten.jimdofree.com
|
||||||
|
Assert.Contains("Status: LOCKER RESERVIERT LEONIE", prompt);
|
||||||
Assert.Contains("Status: FREI", prompt);
|
Assert.Contains("Status: FREI", prompt);
|
||||||
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
|
|
||||||
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ public class ApplicationContext : DbContext
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||||
|
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||||
|
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||||
|
// support custom collation names and would fail EnsureCreated on the test host.
|
||||||
|
const string DeIcu = "de-x-icu";
|
||||||
|
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||||
|
|
||||||
modelBuilder.Entity<Gerbil>(e =>
|
modelBuilder.Entity<Gerbil>(e =>
|
||||||
{
|
{
|
||||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||||
@@ -58,6 +65,14 @@ public class ApplicationContext : DbContext
|
|||||||
// Residency defaults to true (own stock unless explicitly marked external).
|
// Residency defaults to true (own stock unless explicitly marked external).
|
||||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||||
|
|
||||||
|
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||||
|
if (isNpgsql)
|
||||||
|
{
|
||||||
|
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||||
|
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||||
|
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||||
|
}
|
||||||
|
|
||||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||||
@@ -81,6 +96,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(g => g.EnclosureId).OnDelete(DeleteBehavior.SetNull);
|
.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 =>
|
||||||
@@ -89,6 +110,12 @@ public class ApplicationContext : DbContext
|
|||||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
e.HasOne(l => l.Mother).WithMany()
|
e.HasOne(l => l.Mother).WithMany()
|
||||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
// DB-5: ExternalRef = source litter id from extract.py.
|
||||||
|
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||||
|
e.HasIndex(l => l.ExternalRef)
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<HealthRecord>(e =>
|
modelBuilder.Entity<HealthRecord>(e =>
|
||||||
@@ -170,6 +197,15 @@ public class ApplicationContext : DbContext
|
|||||||
modelBuilder.Entity<MailSettings>()
|
modelBuilder.Entity<MailSettings>()
|
||||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||||
|
|
||||||
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
|
if (isNpgsql)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<ColorVariety>()
|
||||||
|
.Property(v => v.Name).UseCollation(DeIcu);
|
||||||
|
modelBuilder.Entity<Contact>()
|
||||||
|
.Property(c => c.Name).UseCollation(DeIcu);
|
||||||
|
}
|
||||||
|
|
||||||
SeedColorVarieties(modelBuilder);
|
SeedColorVarieties(modelBuilder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,69 +282,97 @@ public class ApplicationContext : DbContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
private static void SeedColorVarieties(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
(string Name, string Genotype)[] catalog =
|
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols.
|
||||||
|
// ID = array_index + 1 (stable, never changes — live FKs safe).
|
||||||
|
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID).
|
||||||
|
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs).
|
||||||
|
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display
|
||||||
|
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
|
||||||
|
(string Name, string Genotype, int SortOrder)[] catalog =
|
||||||
{
|
{
|
||||||
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
|
// --- C-locus white / partial albino (IDs 1-3) ---
|
||||||
("Hermelin", "aa chch DD EE GG PP spsp rere"),
|
("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere", 0),
|
||||||
("Himalaya", "AA chch DD EE GG PP spsp rere"),
|
("Hermelin", "aa chch DD EE GG PP spsp rere", 1),
|
||||||
("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
|
("Himalaya", "AA chch DD EE GG PP spsp rere", 2),
|
||||||
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
|
// --- Zobel / colourpoint dark (ID 4) ---
|
||||||
("Agouti", "AA CC DD EE GG PP spsp rere"),
|
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3),
|
||||||
("Schwarz", "aa CC DD EE GG PP spsp rere"),
|
// --- Schimmel family (IDs 5) ---
|
||||||
("Silberagouti", "AA CC DD EE gg PP spsp rere"),
|
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4),
|
||||||
("Anthrazit", "aa CC DD EE gg PP spsp rere"),
|
// --- Standard colours (IDs 6-15) ---
|
||||||
("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
|
("Agouti", "AA CC DD EE GG PP spsp rere", 5),
|
||||||
("Blau", "aa CC dd EE GG PP spsp rere"),
|
("Schwarz", "aa CC DD EE GG PP spsp rere", 6),
|
||||||
("Gold", "AA CC DD EE GG pp spsp rere"),
|
("Silberagouti","AA CC DD EE gg PP spsp rere", 7),
|
||||||
("Platin", "aa CC DD EE GG pp spsp rere"),
|
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8),
|
||||||
("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
|
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9),
|
||||||
("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
|
("Blau", "aa CC dd EE GG PP spsp rere", 10),
|
||||||
("dd Gold", "AA CC dd EE GG pp spsp rere"),
|
("Gold", "AA CC DD EE GG pp spsp rere", 11),
|
||||||
("dd Platin", "aa CC dd EE GG pp spsp rere"),
|
("Platin", "aa CC DD EE GG pp spsp rere", 12),
|
||||||
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
|
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13),
|
||||||
("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
|
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14),
|
||||||
("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
|
// --- Dilute (dd) standard (IDs 16-17) ---
|
||||||
("C-Separator", "aa CC DD ee gg pp spsp rere"),
|
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15),
|
||||||
("Elfenbein", "AA CC DD EE gg pp spsp rere"),
|
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16),
|
||||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
|
// --- REW / Apricot / misc C-locus (IDs 18-28) ---
|
||||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
|
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17),
|
||||||
("Saphir", "aa CC DD EE GG pp spsp rere"),
|
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18),
|
||||||
("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
|
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19),
|
||||||
("Topas", "AA CC DD EE GG pp spsp rere"),
|
("C-Separator", "aa CC DD ee gg pp spsp rere", 20),
|
||||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
|
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||||
("Agouti dd", "AA CC dd EE GG PP spsp rere"),
|
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||||
("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
|
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||||
("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
|
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
||||||
("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
|
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
|
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
||||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
|
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
|
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
|
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
|
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30),
|
||||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
|
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
|
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
|
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
|
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
|
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
|
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||||
("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
|
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
|
// --- Hell variants (IDs 38-48) ---
|
||||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
|
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
|
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||||
("Topas dd", "AA CC dd EE GG pp spsp rere"),
|
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||||
("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
|
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
|
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||||
("Siam", "aa cchmch DD EE GG PP spsp rere"),
|
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||||
("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
|
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||||
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
|
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||||
("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
|
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||||
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
|
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||||
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
|
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||||
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
|
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||||
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
|
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||||
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
|
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||||
("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
|
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||||
|
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||||
|
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||||
|
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56),
|
||||||
|
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57),
|
||||||
|
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59),
|
||||||
|
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61),
|
||||||
|
("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere", 63),
|
||||||
|
("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere", 65),
|
||||||
|
("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere", 66),
|
||||||
|
("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere", 67),
|
||||||
|
("CP-Orangeschimmel","AA cchmcchm DD efef GG PP spsp rere", 68),
|
||||||
|
// GEN-3g / CATALOG-RESEED: CP-*-Hell interleaved (IDs 62-66, SortOrders from backend.json)
|
||||||
|
("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere", 58),
|
||||||
|
("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere", 60),
|
||||||
|
("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere", 62),
|
||||||
|
("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere", 64),
|
||||||
|
("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere", 69),
|
||||||
|
// CATALOG-RESEED: 4 new Dilute (dd) Fuchs varieties appended (IDs 67-70)
|
||||||
|
("Dilute Algierfuchs","AA CC dd ee GG PP spsp rere", 32),
|
||||||
|
("Dilute Goldfuchs", "AA CC dd ee GG pp spsp rere", 33),
|
||||||
|
("Dilute Rotfuchs", "aa CC dd ee GG pp spsp rere", 34),
|
||||||
|
("Dilute Polarfuchs", "AA CC dd ee gg PP spsp rere", 35),
|
||||||
};
|
};
|
||||||
|
|
||||||
var rows = new ColorVariety[catalog.Length];
|
var rows = new ColorVariety[catalog.Length];
|
||||||
@@ -316,11 +380,11 @@ public class ApplicationContext : DbContext
|
|||||||
{
|
{
|
||||||
rows[i] = new ColorVariety
|
rows[i] = new ColorVariety
|
||||||
{
|
{
|
||||||
// Stable, deterministic GUIDs so the HasData seed is migration-stable.
|
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries).
|
||||||
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"),
|
||||||
Name = catalog[i].Name,
|
Name = catalog[i].Name,
|
||||||
CanonicalGenotype = catalog[i].Genotype,
|
CanonicalGenotype = catalog[i].Genotype,
|
||||||
SortOrder = i,
|
SortOrder = catalog[i].SortOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
modelBuilder.Entity<ColorVariety>().HasData(rows);
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
// Request DTOs -----------------------------------------------------------
|
// Request DTOs -----------------------------------------------------------
|
||||||
|
|
||||||
public record GerbilInput(
|
public record GerbilInput(
|
||||||
string Name,
|
string? Name,
|
||||||
Gender Gender,
|
Gender? Gender,
|
||||||
GerbilStatus? Status,
|
GerbilStatus? Status,
|
||||||
Guid? LitterId,
|
Guid? LitterId,
|
||||||
Guid? OriginContactId,
|
Guid? OriginContactId,
|
||||||
|
|||||||
@@ -39,19 +39,18 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
if (string.IsNullOrWhiteSpace(input.Name))
|
if (string.IsNullOrWhiteSpace(input.Name))
|
||||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
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);
|
Apply(g, input, isCreate: true);
|
||||||
db.Gerbils.Add(g);
|
db.Gerbils.Add(g);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.Created($"/gerbils/{g.Id}", ToDto(g));
|
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) =>
|
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);
|
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (g is null) return TypedResults.NotFound();
|
if (g is null) return TypedResults.NotFound();
|
||||||
g.Name = input.Name;
|
|
||||||
Apply(g, input, isCreate: false);
|
Apply(g, input, isCreate: false);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return TypedResults.NoContent();
|
return TypedResults.NoContent();
|
||||||
@@ -77,27 +76,34 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
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)
|
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.Status = i.Status ?? (isCreate ? GerbilStatus.Active : g.Status);
|
||||||
g.LitterId = i.LitterId;
|
g.LitterId = i.LitterId ?? g.LitterId;
|
||||||
g.OriginContactId = i.OriginContactId;
|
g.OriginContactId = i.OriginContactId ?? g.OriginContactId;
|
||||||
g.ReceiverContactId = i.ReceiverContactId;
|
g.ReceiverContactId = i.ReceiverContactId ?? g.ReceiverContactId;
|
||||||
g.EnclosureId = i.EnclosureId;
|
g.EnclosureId = i.EnclosureId ?? g.EnclosureId;
|
||||||
g.ColorVarietyId = i.ColorVarietyId;
|
g.ColorVarietyId = i.ColorVarietyId ?? g.ColorVarietyId;
|
||||||
g.DateOfBirth = i.DateOfBirth;
|
g.DateOfBirth = i.DateOfBirth ?? g.DateOfBirth;
|
||||||
g.DateOfDeath = i.DateOfDeath;
|
g.DateOfDeath = i.DateOfDeath ?? g.DateOfDeath;
|
||||||
g.CauseOfDeath = i.CauseOfDeath;
|
g.CauseOfDeath = i.CauseOfDeath ?? g.CauseOfDeath;
|
||||||
g.GoHomeDate = i.GoHomeDate;
|
g.GoHomeDate = i.GoHomeDate ?? g.GoHomeDate;
|
||||||
g.Genotype = i.Genotype;
|
g.Genotype = i.Genotype ?? g.Genotype;
|
||||||
g.Notes = i.Notes;
|
g.Notes = i.Notes ?? g.Notes;
|
||||||
g.ImportSource = i.ImportSource;
|
g.ImportSource = i.ImportSource ?? g.ImportSource;
|
||||||
g.ExternalRef = i.ExternalRef;
|
g.ExternalRef = i.ExternalRef ?? g.ExternalRef;
|
||||||
g.OriginBreeder = i.OriginBreeder;
|
g.OriginBreeder = i.OriginBreeder ?? g.OriginBreeder;
|
||||||
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
|
g.CharacterTraits = i.CharacterTraits ?? g.CharacterTraits;
|
||||||
g.CharacterNote = i.CharacterNote;
|
g.CharacterNote = i.CharacterNote ?? g.CharacterNote;
|
||||||
g.IsDeaf = i.IsDeaf;
|
g.IsDeaf = i.IsDeaf ?? g.IsDeaf;
|
||||||
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
28
GerbilManagerWebAPI/Endpoints/ImportDocxEndpoints.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
public static class ImportDocxEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapImportDocxEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/import/docx").WithTags("Import");
|
||||||
|
|
||||||
|
// POST /import/docx/dry-run — analyse without writing
|
||||||
|
group.MapPost("/dry-run",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: false)));
|
||||||
|
|
||||||
|
// POST /import/docx/execute — GATED: enriches Gerbils with Litter-Link,
|
||||||
|
// ReceiverContact, GoHomeDate, DateOfDeath, CauseOfDeath (fill-NULL-only).
|
||||||
|
group.MapPost("/execute",
|
||||||
|
async Task<Ok<ImportDocxReport>> (
|
||||||
|
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||||
|
TypedResults.Ok(await new ImportDocxService(db, config, env).RunAsync(execute: true)));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using GerbilManagerWebAPI.Names;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||||
|
///
|
||||||
|
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||||
|
/// frontend maps exactly this code to its German disabled-state hint.
|
||||||
|
/// </summary>
|
||||||
|
public static class NamesEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/names/suggest", async (
|
||||||
|
string? letter,
|
||||||
|
string? gender,
|
||||||
|
string? usages,
|
||||||
|
int count,
|
||||||
|
NameSuggestionService service,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||||
|
return result.Status switch
|
||||||
|
{
|
||||||
|
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||||
|
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||||
|
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||||
|
_ => Results.Json(
|
||||||
|
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.WithTags("Names");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
405
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
405
GerbilManagerWebAPI/Import/ImportDocxService.cs
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Import
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-8d docx loader. Consumes tools/import/output/docx_litters.json +
|
||||||
|
/// docx_animals.json (produced by extract_docx.py) and enriches OR CREATES animals.
|
||||||
|
///
|
||||||
|
/// Load policy (IDEMPOTENT NACHZUG after main WIPE+REIMPORT):
|
||||||
|
/// Match order per docx row:
|
||||||
|
/// 1. ExternalRef "docx-…" (previously created by this loader) → enrich (fill-NULL-only)
|
||||||
|
/// 2. normalize(name)+litterDob in Gerbils (from main import) → enrich (fill-NULL-only)
|
||||||
|
/// 3. No match → CREATE: abgegebene Jungtiere that never appeared in the pedigree charts.
|
||||||
|
///
|
||||||
|
/// Created animals: Status=GivenAway (or Deceased), IsResident=false,
|
||||||
|
/// OriginBreeder='Zucht der Kleinen Chaoten', ImportSource="docx",
|
||||||
|
/// ExternalRef = stable "docx-{ws}-{normname}-{litterDob}[-N]" key (idempotent;
|
||||||
|
/// -N ordinal suffix disambiguates same-name siblings in a litter).
|
||||||
|
///
|
||||||
|
/// NEVER overwrites a manually-set non-null value (fill-NULL-only for all fields).
|
||||||
|
///
|
||||||
|
/// Idempotent: running multiple times is safe. Re-run finds existing rows via ExternalRef.
|
||||||
|
/// Execute wraps all writes in a single transaction (atomic: crash → full rollback).
|
||||||
|
/// Execute is gated by the endpoint; this service only acts when asked.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImportDocxService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
private readonly ApplicationContext _db;
|
||||||
|
private readonly string _sourceDir;
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env)
|
||||||
|
: this(db,
|
||||||
|
config["Import:SourcePath"]
|
||||||
|
?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")))
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public ImportDocxService(ApplicationContext db, string sourceDir)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_sourceDir = sourceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImportDocxReport> RunAsync(bool execute)
|
||||||
|
{
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
var docxLitters = Load<List<DocxLitter>>("docx_litters.json") ?? new();
|
||||||
|
var docxAnimals = Load<List<DocxAnimal>>("docx_animals.json") ?? new();
|
||||||
|
|
||||||
|
if (docxLitters.Count == 0 && docxAnimals.Count == 0)
|
||||||
|
{
|
||||||
|
notes.Add($"Keine Quelldaten in {_sourceDir} (docx_litters.json/docx_animals.json). " +
|
||||||
|
"extract_docx.py zuerst ausführen.");
|
||||||
|
return new ImportDocxReport(false, 0, 0, 0, 0, 0, 0, 0, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Litter lookup by birth date (DayNumber) → list of matching DB litters.
|
||||||
|
// NOTE: WsCode in docx is a litter-size fraction ("4/4", "/5") — NOT a PairingCode.
|
||||||
|
// Date-only lookup with uniqueness guard avoids false links (only link when
|
||||||
|
// exactly one DB litter falls within ±5 days of the docx litter DOB).
|
||||||
|
var littersInDb = await _db.Litters
|
||||||
|
.Select(l => new { l.Id, l.Date })
|
||||||
|
.ToListAsync();
|
||||||
|
var littersByDayNumber = littersInDb
|
||||||
|
.GroupBy(l => l.Date.DayNumber)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)
|
||||||
|
var gerbilsInDb = await _db.Gerbils
|
||||||
|
.Select(g => new { g.Id, g.Name, g.DateOfBirth, g.LitterId,
|
||||||
|
g.ReceiverContactId, g.GoHomeDate, g.DateOfDeath, g.CauseOfDeath,
|
||||||
|
g.ExternalRef })
|
||||||
|
.ToListAsync();
|
||||||
|
var gerbilByKey = gerbilsInDb
|
||||||
|
.Where(g => g.DateOfBirth is not null)
|
||||||
|
.GroupBy(g => NameDobKey(g.Name, g.DateOfBirth!.Value))
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
// ExternalRef → snapshot for previously docx-created animals (idempotency across runs)
|
||||||
|
var docxExternalRefs = gerbilsInDb
|
||||||
|
.Where(g => g.ExternalRef?.StartsWith("docx-") == true)
|
||||||
|
.ToDictionary(g => g.ExternalRef!,
|
||||||
|
g => new { g.Id, g.LitterId, g.GoHomeDate, g.DateOfDeath, g.ReceiverContactId });
|
||||||
|
|
||||||
|
// Contact lookup: normalized name → existing Contact.Id
|
||||||
|
var contactsInDb = await _db.Contacts
|
||||||
|
.Select(c => new { c.Id, c.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
var contactByNorm = contactsInDb
|
||||||
|
.GroupBy(c => NormalizeName(c.Name))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
// ColorVariety lookup: normalized name → Id (for CREATE path Farbschlag matching)
|
||||||
|
var colorVarietyByName = (await _db.ColorVarieties
|
||||||
|
.Select(cv => new { cv.Id, cv.Name })
|
||||||
|
.ToListAsync())
|
||||||
|
.GroupBy(cv => NormalizeName(cv.Name))
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
|
|
||||||
|
int animalsCreated = 0, litterLinked = 0, goHomeFilled = 0, deathFilled = 0;
|
||||||
|
int ownerLinked = 0, ownerCreated = 0, skipped = 0;
|
||||||
|
|
||||||
|
// Ordinal counter for collision-free ExternalRef within this batch.
|
||||||
|
// Two animals with the same base ref (same ws+name+litterDob) get -2, -3 suffixes.
|
||||||
|
var externalRefOrdinals = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
// Belt-and-suspenders: guard against adding the same ExternalRef twice in one run.
|
||||||
|
var batchRefs = new HashSet<string>();
|
||||||
|
|
||||||
|
// --- Planning pass (dry-run counts + execute writes) ---
|
||||||
|
// Execute path is wrapped in a single transaction for atomicity.
|
||||||
|
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? tx = null;
|
||||||
|
if (execute)
|
||||||
|
tx = await _db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var da in docxAnimals)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(da.Name)) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Collision-free ExternalRef: ordinal suffix for same-name siblings
|
||||||
|
var baseRef = DocxExternalRefBase(da);
|
||||||
|
externalRefOrdinals.TryGetValue(baseRef, out var ord);
|
||||||
|
ord++;
|
||||||
|
externalRefOrdinals[baseRef] = ord;
|
||||||
|
var externalRef = ord == 1 ? baseRef : $"{baseRef}-{ord}";
|
||||||
|
|
||||||
|
// Resolve litter: date ±5 days, unambiguous (exactly one candidate)
|
||||||
|
Guid? litterId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.LitterDob))
|
||||||
|
{
|
||||||
|
var litterDob = ParseDate(da.LitterDob);
|
||||||
|
if (litterDob is not null)
|
||||||
|
{
|
||||||
|
var candidates = new List<Guid>();
|
||||||
|
for (int delta = -5; delta <= 5; delta++)
|
||||||
|
{
|
||||||
|
if (littersByDayNumber.TryGetValue(litterDob.Value.DayNumber + delta, out var cl))
|
||||||
|
candidates.AddRange(cl.Select(l => l.Id));
|
||||||
|
}
|
||||||
|
if (candidates.Count == 1)
|
||||||
|
litterId = candidates[0];
|
||||||
|
// If 0 or >1 candidates: no link (avoid false links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animal DOB = litter birth date (docx has no per-animal DOB)
|
||||||
|
var animalDob = litterId is not null
|
||||||
|
? (await _db.Litters.Where(l => l.Id == litterId).Select(l => (DateOnly?)l.Date).FirstOrDefaultAsync())
|
||||||
|
: ParseDate(da.LitterDob);
|
||||||
|
|
||||||
|
if (animalDob is null) { skipped++; continue; }
|
||||||
|
|
||||||
|
var goHomeDate = ParseDate(da.AbgabeDate);
|
||||||
|
var deathDate = ParseDate(da.DeathDate);
|
||||||
|
|
||||||
|
// Resolve receiver contact (lookup-or-create; shared by all paths)
|
||||||
|
Guid? receiverId = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.Owner))
|
||||||
|
{
|
||||||
|
var normOwner = NormalizeName(da.Owner);
|
||||||
|
if (contactByNorm.TryGetValue(normOwner, out var existingContactId))
|
||||||
|
{
|
||||||
|
receiverId = existingContactId;
|
||||||
|
ownerLinked++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ownerCreated++;
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var newContact = new Contact { Id = Guid.NewGuid(), Name = da.Owner.Trim() };
|
||||||
|
_db.Contacts.Add(newContact);
|
||||||
|
await _db.SaveChangesAsync(); // flush within the outer tx
|
||||||
|
receiverId = newContact.Id;
|
||||||
|
contactByNorm[normOwner] = receiverId.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 1: previously docx-created animal (idempotent re-run) ──────────
|
||||||
|
if (docxExternalRefs.TryGetValue(externalRef, out var prevSnap))
|
||||||
|
{
|
||||||
|
bool willLink = litterId is not null && prevSnap.LitterId is null;
|
||||||
|
bool willHome = goHomeDate is not null && prevSnap.GoHomeDate is null;
|
||||||
|
bool willDeath = deathDate is not null && prevSnap.DateOfDeath is null;
|
||||||
|
|
||||||
|
if (willLink) litterLinked++;
|
||||||
|
if (willHome) goHomeFilled++;
|
||||||
|
if (willDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == prevSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 2: main-import animal matched by name+dob ───────────────────────
|
||||||
|
var nameKey = NameDobKey(da.Name, animalDob.Value);
|
||||||
|
if (gerbilByKey.TryGetValue(nameKey, out var gerbilCands))
|
||||||
|
{
|
||||||
|
var gerbilSnap = gerbilCands.FirstOrDefault(g => g.LitterId == null)
|
||||||
|
?? gerbilCands.First();
|
||||||
|
|
||||||
|
bool willLink = litterId is not null && gerbilSnap.LitterId is null;
|
||||||
|
bool willHome = goHomeDate is not null && gerbilSnap.GoHomeDate is null;
|
||||||
|
bool willDeath = deathDate is not null && gerbilSnap.DateOfDeath is null;
|
||||||
|
|
||||||
|
if (willLink) litterLinked++;
|
||||||
|
if (willHome) goHomeFilled++;
|
||||||
|
if (willDeath) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == gerbilSnap.Id);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (willLink) row.LitterId = litterId;
|
||||||
|
if (receiverId is not null && row.ReceiverContactId is null) row.ReceiverContactId = receiverId;
|
||||||
|
if (willHome) row.GoHomeDate = goHomeDate;
|
||||||
|
if (willDeath)
|
||||||
|
{
|
||||||
|
row.DateOfDeath = deathDate;
|
||||||
|
if (!string.IsNullOrWhiteSpace(da.DeathCause) && row.CauseOfDeath is null)
|
||||||
|
row.CauseOfDeath = da.DeathCause.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATH 3: no match → CREATE ────────────────────────────────────────────
|
||||||
|
// Belt-and-suspenders: ordinal should ensure uniqueness, but guard anyway
|
||||||
|
if (!batchRefs.Add(externalRef)) { skipped++; continue; }
|
||||||
|
|
||||||
|
animalsCreated++;
|
||||||
|
if (litterId is not null) litterLinked++;
|
||||||
|
if (goHomeDate is not null) goHomeFilled++;
|
||||||
|
if (deathDate is not null) deathFilled++;
|
||||||
|
|
||||||
|
if (execute)
|
||||||
|
{
|
||||||
|
colorVarietyByName.TryGetValue(NormalizeName(da.Farbschlag ?? ""), out var cvId);
|
||||||
|
|
||||||
|
_db.Gerbils.Add(new Gerbil
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = da.Name.Trim(),
|
||||||
|
DateOfBirth = animalDob,
|
||||||
|
Gender = ParseGender(da.Gender),
|
||||||
|
Status = deathDate is not null ? GerbilStatus.Deceased : GerbilStatus.GivenAway,
|
||||||
|
LitterId = litterId,
|
||||||
|
ReceiverContactId = receiverId,
|
||||||
|
GoHomeDate = goHomeDate,
|
||||||
|
DateOfDeath = deathDate,
|
||||||
|
CauseOfDeath = string.IsNullOrWhiteSpace(da.DeathCause) ? null : da.DeathCause.Trim(),
|
||||||
|
ColorVarietyId = cvId == default ? null : cvId,
|
||||||
|
OriginBreeder = "Zucht der Kleinen Chaoten",
|
||||||
|
IsResident = false,
|
||||||
|
ImportSource = "docx",
|
||||||
|
ExternalRef = externalRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush all gerbil inserts + enrich updates in one shot (within the tx)
|
||||||
|
if (execute && (animalsCreated + litterLinked + goHomeFilled + deathFilled + ownerCreated) > 0)
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (tx is not null) await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// tx.DisposeAsync (in finally) rolls back if not committed
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (tx is not null) await tx.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.Add($"Quelle: {docxLitters.Count} Würfe, {docxAnimals.Count} Tier-Zeilen aus der docx.");
|
||||||
|
notes.Add($"Neu angelegt: {animalsCreated} Jungtiere (abgegeben, nicht in Stammbäumen).");
|
||||||
|
notes.Add($"Litter-Links: {litterLinked} Tiere einem Wurf zugeordnet (DOB-Match ±5 Tage, eindeutig).");
|
||||||
|
notes.Add($"Abnehmer: {ownerLinked} bestehende Kontakte verknüpft, {ownerCreated} neue Kontakte angelegt.");
|
||||||
|
notes.Add($"GoHomeDate: {goHomeFilled} Abgabe-Daten nachgetragen.");
|
||||||
|
notes.Add($"Tod-Datum: {deathFilled} Todesdaten nachgetragen.");
|
||||||
|
notes.Add($"Übersprungen: {skipped} Zeilen (kein Name oder kein Datum).");
|
||||||
|
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/docx/execute schreibt die Änderungen.");
|
||||||
|
|
||||||
|
return new ImportDocxReport(execute, animalsCreated, litterLinked, ownerLinked + ownerCreated,
|
||||||
|
goHomeFilled, deathFilled, ownerCreated, skipped, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private T? Load<T>(string file)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(_sourceDir, file);
|
||||||
|
if (!File.Exists(path)) return default;
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
return JsonSerializer.Deserialize<T>(fs, Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDate(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
var m = System.Text.RegularExpressions.Regex.Match(s,
|
||||||
|
@"(\d{1,2})\.(\d{1,2})\.(\d{2,4})");
|
||||||
|
if (!m.Success) return null;
|
||||||
|
int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value);
|
||||||
|
int y = int.Parse(m.Groups[3].Value);
|
||||||
|
if (y < 100) y += 2000;
|
||||||
|
try { return new DateOnly(y, mo, d); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameDobKey(string name, DateOnly dob)
|
||||||
|
{
|
||||||
|
var n = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
(name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
|
return $"{n}|{dob:yyyy-MM-dd}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeName(string name)
|
||||||
|
{
|
||||||
|
var n = (name ?? "").ToLowerInvariant();
|
||||||
|
n = System.Text.RegularExpressions.Regex.Replace(n, @"\s+", " ").Trim();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base ExternalRef key (before ordinal disambiguation). Two animals sharing the same
|
||||||
|
/// ws+name+litterDob get this same base; the caller appends -2, -3 … for duplicates.
|
||||||
|
/// </summary>
|
||||||
|
internal static string DocxExternalRefBase(DocxAnimal da)
|
||||||
|
{
|
||||||
|
var ws = (da.WsCode ?? "").Replace(" ", "").ToLowerInvariant();
|
||||||
|
var name = System.Text.RegularExpressions.Regex.Replace(
|
||||||
|
(da.Name ?? "").ToLowerInvariant(), @"[^a-z0-9äöüß]", "");
|
||||||
|
return $"docx-{ws}-{name}-{da.LitterDob}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gender ParseGender(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return Gender.unknown;
|
||||||
|
var lower = s.ToLowerInvariant();
|
||||||
|
if (lower == "male" || lower == "m" || lower.Contains("männ")) return Gender.male;
|
||||||
|
if (lower == "female" || lower == "f" || lower == "w" || lower.Contains("weibl")) return Gender.female;
|
||||||
|
return Gender.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Source shapes (from extract_docx.py output) ----
|
||||||
|
|
||||||
|
public sealed class DocxLitter
|
||||||
|
{
|
||||||
|
public string LitterId { get; set; } = "";
|
||||||
|
public string Dob { get; set; } = "";
|
||||||
|
public string MotherName { get; set; } = "";
|
||||||
|
public string FatherName { get; set; } = "";
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DocxAnimal
|
||||||
|
{
|
||||||
|
public string WsCode { get; set; } = "";
|
||||||
|
public string LitterDob { get; set; } = "";
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string Farbschlag { get; set; } = "";
|
||||||
|
public string Gender { get; set; } = "";
|
||||||
|
public string Owner { get; set; } = "";
|
||||||
|
public string AbgabeDate { get; set; } = "";
|
||||||
|
public string AbgabeWeight { get; set; } = "";
|
||||||
|
public string DeathDate { get; set; } = "";
|
||||||
|
public string DeathCause { get; set; } = "";
|
||||||
|
public string PartnerName { get; set; } = "";
|
||||||
|
public string PartnerDob { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report ----
|
||||||
|
|
||||||
|
public sealed record ImportDocxReport(
|
||||||
|
bool Executed,
|
||||||
|
int Created,
|
||||||
|
int LitterLinked,
|
||||||
|
int OwnerLinked,
|
||||||
|
int GoHomeFilled,
|
||||||
|
int DeathFilled,
|
||||||
|
int ContactsCreated,
|
||||||
|
int Skipped,
|
||||||
|
IReadOnlyList<string> Notes);
|
||||||
|
}
|
||||||
@@ -82,12 +82,16 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
ResidencySummary? Residency = null);
|
ResidencySummary? Residency = null);
|
||||||
|
|
||||||
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
|
||||||
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
|
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).
|
||||||
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
|
/// 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,
|
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
|
||||||
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
|
||||||
int ParentFksBackfilled = 0);
|
int ParentFksBackfilled = 0, int WithoutDate = 0);
|
||||||
|
|
||||||
public sealed record AnimalSummary(
|
public sealed record AnimalSummary(
|
||||||
int InSource,
|
int InSource,
|
||||||
@@ -98,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,
|
||||||
|
|||||||
@@ -71,17 +71,21 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
else if (conf == "niedrig") dateOnly++;
|
else if (conf == "niedrig") dateOnly++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
// existing rows (idempotency).
|
||||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
|
||||||
var existingGerbilExtRefs = await _db.Gerbils
|
var existingGerbilExtRefs = await _db.Gerbils
|
||||||
.Where(g => g.ExternalRef != null)
|
.Where(g => g.ExternalRef != null)
|
||||||
.Select(g => g.ExternalRef!).ToListAsync();
|
.Select(g => g.ExternalRef!).ToListAsync();
|
||||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||||
|
|
||||||
var existingLitterKeys = await _db.Litters
|
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||||
var existingLitterKeySet = existingLitterKeys
|
var existingLitterData = await _db.Litters
|
||||||
|
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||||
|
var existingLitterKeySet = existingLitterData
|
||||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||||
|
var existingLitterExtRefSet = existingLitterData
|
||||||
|
.Where(x => x.ExternalRef != null)
|
||||||
|
.Select(x => x.ExternalRef!).ToHashSet();
|
||||||
|
|
||||||
// colour-variety name -> id (case-insensitive)
|
// colour-variety name -> id (case-insensitive)
|
||||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||||
@@ -94,31 +98,41 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||||
|
|
||||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||||
int littersCreated = 0, littersExisting = 0;
|
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||||
|
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||||
|
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||||
|
// so skip them early — they can never be created or linked to animals.
|
||||||
|
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||||
foreach (var sl in litters)
|
foreach (var sl in litters)
|
||||||
{
|
{
|
||||||
var date = ParseDate(sl.Date);
|
var date = ParseDate(sl.Date);
|
||||||
|
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
|
||||||
|
|
||||||
var name = $"Wurf {sl.LitterId}".Trim();
|
var name = $"Wurf {sl.LitterId}".Trim();
|
||||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||||
|
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||||
|
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||||
|
{ littersExisting++; continue; }
|
||||||
|
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
litterIdMap[sl.Id] = id;
|
litterIdMap[sl.Id] = id;
|
||||||
littersCreated++;
|
littersCreated++;
|
||||||
if (execute && date is DateOnly d)
|
if (execute)
|
||||||
{
|
{
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Name = name,
|
||||||
Date = d,
|
Date = date.Value,
|
||||||
TotalBorn = sl.TotalBorn,
|
TotalBorn = sl.TotalBorn,
|
||||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||||
|
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (samples.Count < 8 && date is not null)
|
if (samples.Count < 8)
|
||||||
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
|
||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
@@ -131,31 +145,71 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
// name+DOB -> gid index, across EXISTING rows AND this run's planned animals, so that
|
// 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.
|
// chart-position parentRefs (PEDIGREE-LINK) can resolve a parent to a real gerbil id.
|
||||||
var existingRows = await _db.Gerbils
|
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>();
|
var gidByNameDob = new Dictionary<string, Guid>();
|
||||||
foreach (var g in existingRows)
|
foreach (var g in existingRows)
|
||||||
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
gidByNameDob[NameDobKey(g.Name, g.DateOfBirth)] = g.Id;
|
||||||
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
var existingLitterByExtRef = existingRows.Where(g => g.ExternalRef != null)
|
||||||
.ToDictionary(g => g.ExternalRef!, g => g.LitterId);
|
.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).
|
// 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));
|
||||||
@@ -163,7 +217,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
if (!exists) gidByNameDob.TryAdd(NameDobKey(a.Name, ParseDate(a.Dob)), gid);
|
||||||
|
|
||||||
var currentLitter = exists && existingLitterByExtRef.TryGetValue(a.Id, out var el) ? el : null;
|
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
|
// PASS 1.5: PEDIGREE-LINK — synthesize/reuse a litter from chart-position parentRefs for
|
||||||
@@ -234,10 +289,15 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
litterParents[lid] = (f, m);
|
litterParents[lid] = (f, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
|
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below).
|
||||||
// before the animals — the offspring AND the parent gerbils are created in the loop
|
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle;
|
||||||
// below, so a single SaveChanges at the end lets EF order parents→litters→offspring
|
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted.
|
||||||
// (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
|
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>();
|
||||||
|
|
||||||
|
// PASS 2: stage synthesized litters (parents already guarded above). Litters are added
|
||||||
|
// with FatherId/MotherId = null (deferred) so that the SaveChanges below has only a
|
||||||
|
// one-directional Gerbil→Litter dependency — no Litter→Gerbil FKs in the same batch,
|
||||||
|
// which would cause EF's topo-sort to throw "circular dependency detected".
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
// reuse an existing litter with the same parents+date instead of duplicating.
|
// reuse an existing litter with the same parents+date instead of duplicating.
|
||||||
@@ -261,13 +321,17 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
derivedLitters--;
|
derivedLitters--;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Defer FatherId/MotherId: both parent gerbils and offspring gerbils may be [Added]
|
||||||
|
// in this same batch. Setting them now causes EF circular dependency
|
||||||
|
// (Gerbil[Added] ← Litter.MotherId [Added] ← Gerbil.LitterId [Added]).
|
||||||
|
synthLitterPendingParents[sl.Id] = (sl.Father, sl.Mother);
|
||||||
_db.Litters.Add(new Litter
|
_db.Litters.Add(new Litter
|
||||||
{
|
{
|
||||||
Id = sl.Id,
|
Id = sl.Id,
|
||||||
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
|
||||||
Date = sl.Date ?? default,
|
Date = sl.Date ?? default,
|
||||||
FatherId = sl.Father,
|
FatherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
MotherId = sl.Mother,
|
MotherId = null, // deferred — applied after gerbils SaveChanges
|
||||||
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -382,6 +446,20 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
}
|
}
|
||||||
if (execute) await _db.SaveChangesAsync();
|
if (execute) await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Apply deferred synth litter parent FKs — all new gerbils are now persisted in the DB,
|
||||||
|
// so no cycle. FK guard already applied above (persisted set); values in the dict are safe.
|
||||||
|
if (execute && synthLitterPendingParents.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var (litId, (f, m)) in synthLitterPendingParents)
|
||||||
|
{
|
||||||
|
var row = await _db.Litters.FindAsync(litId);
|
||||||
|
if (row is null) continue;
|
||||||
|
if (f is not null) row.FatherId = f;
|
||||||
|
if (m is not null) row.MotherId = m;
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
// ---- back-link Wurfchronik litter parents by name (best effort) ----
|
||||||
if (execute)
|
if (execute)
|
||||||
{
|
{
|
||||||
@@ -454,6 +532,80 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
|
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.");
|
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
|
||||||
if (parentLinksAdded > 0)
|
if (parentLinksAdded > 0)
|
||||||
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
|
||||||
@@ -461,22 +613,29 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
if (parentFksBackfilled > 0)
|
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($"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).");
|
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);
|
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(
|
||||||
Executed: execute,
|
Executed: execute,
|
||||||
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
|
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate),
|
||||||
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,
|
||||||
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
|
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule,
|
||||||
|
HerkunftBackfilled: herkunftBackfilled, FarbschlagWouldRebackfill: farbschlagWouldRebackfill));
|
||||||
}
|
}
|
||||||
|
|
||||||
private T? Load<T>(string file)
|
private T? Load<T>(string file)
|
||||||
@@ -509,6 +668,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
|
||||||
@@ -566,7 +742,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
/// <summary>Per-animal plan computed before any write so synthesis can run in dry-run too.</summary>
|
||||||
private sealed record AnimalPlan(
|
private sealed record AnimalPlan(
|
||||||
SourceAnimal A, Guid Gid, bool Exists, Guid? WurfLitterId,
|
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>
|
/// <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);
|
private sealed record SynthLitter(Guid Id, Guid? Father, Guid? Mother, DateOnly? Date, string Confidence);
|
||||||
|
|||||||
1337
GerbilManagerWebAPI/Migrations/20260606134002_ReseedColorVarietiesGen3g.Designer.cs
generated
Normal file
1337
GerbilManagerWebAPI/Migrations/20260606134002_ReseedColorVarietiesGen3g.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReseedColorVarietiesGen3g : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000062"), "AA cchmch DD EE GG PP spsp rere", "CP-Agouti-Hell", 61 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000063"), "AA cchmch DD EE gg PP spsp rere", "CP-Silberagouti-Hell", 62 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000064"), "AA cchmch DD ee GG PP spsp rere", "CP-Algierfuchs-Hell", 63 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000065"), "AA cchmch DD ee gg PP spsp rere", "CP-Polarfuchs-Hell", 64 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000066"), "AA cchmch DD efef GG PP spsp rere", "CP-Orangeschimmel-Hell", 65 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000065"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
1387
GerbilManagerWebAPI/Migrations/20260606154816_UniqueExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Gerbils_ExternalRef",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGermanCollation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExternalRef",
|
||||||
|
table: "Litters",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginBreeder",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "NameSearch",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "ColorVarieties",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
collation: "de-x-icu",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Litters_ExternalRef",
|
||||||
|
table: "Litters",
|
||||||
|
column: "ExternalRef",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"ExternalRef\" IS NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Litters_ExternalRef",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalRef",
|
||||||
|
table: "Litters");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginBreeder",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true,
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "NameSearch",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true,
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Contacts",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "ColorVarieties",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldCollation: "de-x-icu");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterExternalRef : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLitterLetter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LitterLetter",
|
||||||
|
table: "Litters",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LitterLetter",
|
||||||
|
table: "Litters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
1430
GerbilManagerWebAPI/Migrations/20260606202850_ReseedColorVarietiesAR5.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReseedColorVarietiesAR5 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Gold");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Platin");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Agouti");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Silberagouti");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Kohlfuchs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Dilute Anthrazit");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 36);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 37);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 38);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 39);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 40);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 41);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 43);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 44);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 45);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 46);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 47);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 48);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 49);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 50);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 51);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Dilute Topas", 52 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Dilute Blaufuchs", 53 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 54);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 55);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 56);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 57);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 59);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 61);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 63);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 65);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 66);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 67);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 68);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 58);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 60);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 62);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 69);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
columns: new[] { "Id", "CanonicalGenotype", "Name", "SortOrder" },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000067"), "AA CC dd ee GG PP spsp rere", "Dilute Algierfuchs", 32 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000068"), "AA CC dd ee GG pp spsp rere", "Dilute Goldfuchs", 33 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000069"), "aa CC dd ee GG pp spsp rere", "Dilute Rotfuchs", 34 },
|
||||||
|
{ new Guid("00000000-0000-0000-0000-000000000070"), "AA CC dd ee gg PP spsp rere", "Dilute Polarfuchs", 35 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000067"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000068"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000069"));
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000070"));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
|
column: "Name",
|
||||||
|
value: "dd Gold");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
|
column: "Name",
|
||||||
|
value: "dd Platin");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Agouti dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Silberagouti dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Kohlfuchs dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
|
column: "Name",
|
||||||
|
value: "Anthrazit dd");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 32);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 33);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 34);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 35);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 36);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 37);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 38);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 39);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 40);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 41);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 42);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 43);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 44);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 45);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 46);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 47);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Topas dd", 48 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
|
columns: new[] { "Name", "SortOrder" },
|
||||||
|
values: new object[] { "Blaufuchs dd", 49 });
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 50);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 51);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 52);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 53);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 54);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 55);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 56);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 57);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 58);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 59);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 60);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 61);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 62);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 63);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "ColorVarieties",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
column: "SortOrder",
|
||||||
|
value: 65);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
@@ -320,14 +321,14 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
Id = new Guid("00000000-0000-0000-0000-000000000016"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "dd Gold",
|
Name = "Dilute Gold",
|
||||||
SortOrder = 15
|
SortOrder = 15
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
Id = new Guid("00000000-0000-0000-0000-000000000017"),
|
||||||
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "aa CC dd EE GG pp spsp rere",
|
||||||
Name = "dd Platin",
|
Name = "Dilute Platin",
|
||||||
SortOrder = 16
|
SortOrder = 16
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -411,28 +412,28 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
Id = new Guid("00000000-0000-0000-0000-000000000029"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG PP spsp rere",
|
||||||
Name = "Agouti dd",
|
Name = "Dilute Agouti",
|
||||||
SortOrder = 28
|
SortOrder = 28
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
Id = new Guid("00000000-0000-0000-0000-000000000030"),
|
||||||
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "AA CC dd EE gg PP spsp rere",
|
||||||
Name = "Silberagouti dd",
|
Name = "Dilute Silberagouti",
|
||||||
SortOrder = 29
|
SortOrder = 29
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
Id = new Guid("00000000-0000-0000-0000-000000000031"),
|
||||||
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC dd ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs dd",
|
Name = "Dilute Kohlfuchs",
|
||||||
SortOrder = 30
|
SortOrder = 30
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
Id = new Guid("00000000-0000-0000-0000-000000000032"),
|
||||||
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
CanonicalGenotype = "aa CC dd EE gg PP spsp rere",
|
||||||
Name = "Anthrazit dd",
|
Name = "Dilute Anthrazit",
|
||||||
SortOrder = 31
|
SortOrder = 31
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
@@ -440,203 +441,266 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
Id = new Guid("00000000-0000-0000-0000-000000000033"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Silberschimmel",
|
Name = "Silberschimmel",
|
||||||
SortOrder = 32
|
SortOrder = 36
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||||
Name = "Polarfuchsschimmel",
|
Name = "Polarfuchsschimmel",
|
||||||
SortOrder = 33
|
SortOrder = 37
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||||
Name = "Algierfuchsschimmel",
|
Name = "Algierfuchsschimmel",
|
||||||
SortOrder = 34
|
SortOrder = 38
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel",
|
Name = "Kohlfuchsschimmel",
|
||||||
SortOrder = 35
|
SortOrder = 39
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||||
Name = "Blaufuchsschimmel",
|
Name = "Blaufuchsschimmel",
|
||||||
SortOrder = 36
|
SortOrder = 40
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
Id = new Guid("00000000-0000-0000-0000-000000000038"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs, hell",
|
Name = "Kohlfuchs, hell",
|
||||||
SortOrder = 37
|
SortOrder = 41
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
Id = new Guid("00000000-0000-0000-0000-000000000039"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG pp spsp rere",
|
||||||
Name = "Goldfuchs, hell",
|
Name = "Goldfuchs, hell",
|
||||||
SortOrder = 38
|
SortOrder = 42
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||||
Name = "Goldfuchsschimmel",
|
Name = "Goldfuchsschimmel",
|
||||||
SortOrder = 39
|
SortOrder = 43
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
Id = new Guid("00000000-0000-0000-0000-000000000041"),
|
||||||
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC DD EE GG pp spsp rere",
|
||||||
Name = "Gold-Hell",
|
Name = "Gold-Hell",
|
||||||
SortOrder = 40
|
SortOrder = 44
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
Id = new Guid("00000000-0000-0000-0000-000000000042"),
|
||||||
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee gg PP spsp rere",
|
||||||
Name = "Blaufuchs, hell",
|
Name = "Blaufuchs, hell",
|
||||||
SortOrder = 41
|
SortOrder = 45
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||||
Name = "Rotfuchsschimmel",
|
Name = "Rotfuchsschimmel",
|
||||||
SortOrder = 42
|
SortOrder = 46
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
Id = new Guid("00000000-0000-0000-0000-000000000044"),
|
||||||
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee gg PP spsp rere",
|
||||||
Name = "Polarfuchs, hell",
|
Name = "Polarfuchs, hell",
|
||||||
SortOrder = 43
|
SortOrder = 47
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||||
Name = "Kohlfuchsschimmel, hell",
|
Name = "Kohlfuchsschimmel, hell",
|
||||||
SortOrder = 44
|
SortOrder = 48
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
Id = new Guid("00000000-0000-0000-0000-000000000046"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG pp spsp rere",
|
||||||
Name = "Rotfuchs, hell",
|
Name = "Rotfuchs, hell",
|
||||||
SortOrder = 45
|
SortOrder = 49
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
Id = new Guid("00000000-0000-0000-0000-000000000047"),
|
||||||
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "aa CC DD ee GG PP spsp rere",
|
||||||
Name = "Kohlfuchs-Hell",
|
Name = "Kohlfuchs-Hell",
|
||||||
SortOrder = 46
|
SortOrder = 50
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
Id = new Guid("00000000-0000-0000-0000-000000000048"),
|
||||||
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA CC DD ee GG PP spsp rere",
|
||||||
Name = "Algierfuchs, hell",
|
Name = "Algierfuchs, hell",
|
||||||
SortOrder = 47
|
SortOrder = 51
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
Id = new Guid("00000000-0000-0000-0000-000000000049"),
|
||||||
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
CanonicalGenotype = "AA CC dd EE GG pp spsp rere",
|
||||||
Name = "Topas dd",
|
Name = "Dilute Topas",
|
||||||
SortOrder = 48
|
SortOrder = 52
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||||
Name = "Blaufuchs dd",
|
Name = "Dilute Blaufuchs",
|
||||||
SortOrder = 49
|
SortOrder = 53
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
Id = new Guid("00000000-0000-0000-0000-000000000051"),
|
||||||
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "Marder",
|
Name = "Marder",
|
||||||
SortOrder = 50
|
SortOrder = 54
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
Id = new Guid("00000000-0000-0000-0000-000000000052"),
|
||||||
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE GG PP spsp rere",
|
||||||
Name = "Siam",
|
Name = "Siam",
|
||||||
SortOrder = 51
|
SortOrder = 55
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
Id = new Guid("00000000-0000-0000-0000-000000000053"),
|
||||||
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
CanonicalGenotype = "aa cchmch DD EE gg PP spsp rere",
|
||||||
Name = "Zobel-Hell",
|
Name = "Zobel-Hell",
|
||||||
SortOrder = 52
|
SortOrder = 56
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
Id = new Guid("00000000-0000-0000-0000-000000000054"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
Name = "CP-Agouti",
|
Name = "CP-Agouti",
|
||||||
SortOrder = 53
|
SortOrder = 57
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
Id = new Guid("00000000-0000-0000-0000-000000000055"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
Name = "CP-Silberagouti",
|
Name = "CP-Silberagouti",
|
||||||
SortOrder = 54
|
SortOrder = 59
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
Id = new Guid("00000000-0000-0000-0000-000000000056"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
Name = "CP-Algierfuchs",
|
Name = "CP-Algierfuchs",
|
||||||
SortOrder = 55
|
SortOrder = 61
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
Id = new Guid("00000000-0000-0000-0000-000000000057"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
Name = "CP-Polarfuchs",
|
Name = "CP-Polarfuchs",
|
||||||
SortOrder = 56
|
SortOrder = 63
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
Id = new Guid("00000000-0000-0000-0000-000000000058"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
Name = "CP-Fuchs",
|
Name = "CP-Fuchs",
|
||||||
SortOrder = 57
|
SortOrder = 65
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
Id = new Guid("00000000-0000-0000-0000-000000000059"),
|
||||||
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
CanonicalGenotype = "AA cchmch dd ee GG PP spsp rere",
|
||||||
Name = "CP-Fuchs-Hell",
|
Name = "CP-Fuchs-Hell",
|
||||||
SortOrder = 58
|
SortOrder = 66
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
Id = new Guid("00000000-0000-0000-0000-000000000060"),
|
||||||
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
Name = "CP-Blaufuchs",
|
Name = "CP-Blaufuchs",
|
||||||
SortOrder = 59
|
SortOrder = 67
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
Id = new Guid("00000000-0000-0000-0000-000000000061"),
|
||||||
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
CanonicalGenotype = "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
Name = "CP-Orangeschimmel",
|
Name = "CP-Orangeschimmel",
|
||||||
|
SortOrder = 68
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000062"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
Name = "CP-Agouti-Hell",
|
||||||
|
SortOrder = 58
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000063"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
Name = "CP-Silberagouti-Hell",
|
||||||
SortOrder = 60
|
SortOrder = 60
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000064"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
Name = "CP-Algierfuchs-Hell",
|
||||||
|
SortOrder = 62
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000065"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
Name = "CP-Polarfuchs-Hell",
|
||||||
|
SortOrder = 64
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000066"),
|
||||||
|
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
Name = "CP-Orangeschimmel-Hell",
|
||||||
|
SortOrder = 69
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000067"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee GG PP spsp rere",
|
||||||
|
Name = "Dilute Algierfuchs",
|
||||||
|
SortOrder = 32
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000068"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee GG pp spsp rere",
|
||||||
|
Name = "Dilute Goldfuchs",
|
||||||
|
SortOrder = 33
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000069"),
|
||||||
|
CanonicalGenotype = "aa CC dd ee GG pp spsp rere",
|
||||||
|
Name = "Dilute Rotfuchs",
|
||||||
|
SortOrder = 34
|
||||||
|
},
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = new Guid("00000000-0000-0000-0000-000000000070"),
|
||||||
|
CanonicalGenotype = "AA CC dd ee gg PP spsp rere",
|
||||||
|
Name = "Dilute Polarfuchs",
|
||||||
|
SortOrder = 35
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -654,7 +718,8 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -742,16 +807,19 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("NameSearch")
|
b.Property<string>("NameSearch")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("OriginBreeder")
|
b.Property<string>("OriginBreeder")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text")
|
||||||
|
.UseCollation("de-x-icu");
|
||||||
|
|
||||||
b.Property<Guid?>("OriginContactId")
|
b.Property<Guid?>("OriginContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
@@ -772,6 +840,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");
|
||||||
@@ -855,9 +927,15 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalRef")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("FatherId")
|
b.Property<Guid?>("FatherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("LitterLetter")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<Guid?>("MotherId")
|
b.Property<Guid?>("MotherId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -876,6 +954,10 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalRef")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("FatherId");
|
b.HasIndex("FatherId");
|
||||||
|
|
||||||
b.HasIndex("MotherId");
|
b.HasIndex("MotherId");
|
||||||
|
|||||||
@@ -25,5 +25,14 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||||
public string? PairingCode { get; set; }
|
public string? PairingCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
|
||||||
|
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||||
|
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||||
|
public string? ExternalRef { get; set; }
|
||||||
|
|
||||||
|
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||||
|
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||||
|
public string? LitterLetter { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Names
|
||||||
|
{
|
||||||
|
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
||||||
|
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
||||||
|
}
|
||||||
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Ai;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Names
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
|
||||||
|
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
|
||||||
|
/// Returns NotConfigured when the AI section is missing — callers map to 503.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
|
||||||
|
{
|
||||||
|
private readonly OpenAiChatClient _client = new(http, options);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<NameSuggestionResult> SuggestAsync(
|
||||||
|
string? letter, string? gender, string? usages, int count,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var aiResult = await _client.CompleteAsync(
|
||||||
|
BuildSystemPrompt(),
|
||||||
|
BuildUserPrompt(letter, gender, usages, count),
|
||||||
|
ct);
|
||||||
|
|
||||||
|
if (aiResult.Status == AiCallStatus.NotConfigured)
|
||||||
|
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
|
||||||
|
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
|
||||||
|
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
|
||||||
|
|
||||||
|
var suggestions = ParseSuggestions(aiResult.Text);
|
||||||
|
return suggestions is null
|
||||||
|
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
|
||||||
|
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildSystemPrompt() =>
|
||||||
|
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||||
|
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||||
|
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||||
|
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
||||||
|
|
||||||
|
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"Schlage {count} Rennmaus-Namen vor");
|
||||||
|
if (!string.IsNullOrWhiteSpace(letter))
|
||||||
|
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
|
||||||
|
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||||
|
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||||
|
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||||
|
if (!string.IsNullOrWhiteSpace(usages))
|
||||||
|
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||||
|
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||||
|
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||||
|
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||||
|
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
||||||
|
/// its JSON output, then deserialises the array.
|
||||||
|
/// </summary>
|
||||||
|
internal static List<NameSuggestion>? ParseSuggestions(string raw)
|
||||||
|
{
|
||||||
|
var text = raw.Trim();
|
||||||
|
|
||||||
|
// Strip ```json ... ``` or ``` ... ``` fences.
|
||||||
|
if (text.StartsWith("```", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var firstNewline = text.IndexOf('\n');
|
||||||
|
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
|
||||||
|
if (text.EndsWith("```", StringComparison.Ordinal))
|
||||||
|
text = text[..^3].TrimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the JSON array bounds defensively.
|
||||||
|
var start = text.IndexOf('[');
|
||||||
|
var end = text.LastIndexOf(']');
|
||||||
|
if (start < 0 || end <= start) return null;
|
||||||
|
text = text[start..(end + 1)];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
||||||
|
|
||||||
|
public sealed record NameSuggestionResult(
|
||||||
|
NameSuggestionStatus Status,
|
||||||
|
List<NameSuggestion>? Suggestions,
|
||||||
|
string? Error);
|
||||||
|
}
|
||||||
@@ -1,5 +1,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;
|
||||||
|
|
||||||
@@ -44,9 +45,22 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
|||||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||||
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||||
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
// 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>();
|
||||||
@@ -84,11 +98,13 @@ app.MapInbreedingEndpoints();
|
|||||||
app.MapPhotoEndpoints();
|
app.MapPhotoEndpoints();
|
||||||
app.MapSaleAdEndpoints();
|
app.MapSaleAdEndpoints();
|
||||||
app.MapImportEndpoints();
|
app.MapImportEndpoints();
|
||||||
|
app.MapImportDocxEndpoints();
|
||||||
app.MapContractEndpoints();
|
app.MapContractEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapExportEndpoints();
|
app.MapExportEndpoints();
|
||||||
app.MapCmsEndpoints();
|
app.MapCmsEndpoints();
|
||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
|
app.MapNamesEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace GerbilManagerWebAPI.SaleAd
|
namespace GerbilManagerWebAPI.SaleAd
|
||||||
@@ -13,37 +13,31 @@ namespace GerbilManagerWebAPI.SaleAd
|
|||||||
public static class SaleAdPromptBuilder
|
public static class SaleAdPromptBuilder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Two few-shot examples derived from the documented listing template.
|
/// Two verbatim listings from kleine-chaoten.jimdofree.com (retrieved 2026-06-06).
|
||||||
/// NOTE: the research file documents the format and one real tagline;
|
/// These are real listings in the breeder's own voice — the strongest grounding source.
|
||||||
/// these examples are synthesized to that template. Swap in verbatim
|
|
||||||
/// listings from kleine-chaoten.jimdofree.com when available.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const string ExampleListing1 = """
|
private const string ExampleListing1 = """
|
||||||
Status: FREI
|
Status: LOCKER RESERVIERT LEONIE
|
||||||
|
|
||||||
„Großer und kleiner Bruder Dynamik“ – Männliches Rennmaus-Duo sucht ein liebevolles Zuhause
|
„Großer und kleiner Bruder Dynamik” – Männliches Rennmaus Duo sucht ein liebevolles Zuhause
|
||||||
|
|
||||||
Balu – CP-Agouti, geboren am 12.03.2024
|
Hier steht ein außergewöhnlich harmonisches Rennmaus-Duo zur Abgabe frei, das nicht nur durch seinen Charakter, sondern auch durch seine besondere Geschichte überzeugt. Die zwei verhalten sich als ob sie Brüder wären und sind unzertrennlich. Das Duo kann ab sofort ausziehen.
|
||||||
Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig.
|
|
||||||
|
|
||||||
Benny – Schwarz Schecke, geboren am 12.03.2024
|
Der Ältere von beiden trägt die Farbe CP-Agouti und ist am 11.11.2025 geboren. Er ist ein neugieriger, aktiver und sozialer kleiner Kerl. Er liebt es, seine Umgebung zu erkunden, ist gerne in Bewegung und bringt dabei eine angenehme Ausgeglichenheit mit. Er wirkt sicher, aufmerksam und zeigt ein rundum unkompliziertes Wesen.
|
||||||
Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest — getrennt werden die beiden deshalb nicht.
|
|
||||||
|
|
||||||
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
Sein, etwas jünger, Partner trägt die Farbe Schwarz Schecke und ist am 26.02.2026 geboren. Statt des in diesem Alter oft typischen Kräftemessens zeigt er ein erstaunlich sanftes, ruhiges und sehr soziales Verhalten. Man merkt ihm an, wie feinfühlig und verträglich er ist.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string ExampleListing2 = """
|
private const string ExampleListing2 = """
|
||||||
Status: LOCKER RESERVIERT Anna
|
Status: FREI
|
||||||
|
|
||||||
„Zwei Schwestern, ein Herz und ganz viel Neugier“ – Weibliches Duo sucht seine Menschen
|
„Unzertrennliches Mutter-Tochter Duo sucht gemeinsam neue Körnergeber”
|
||||||
|
|
||||||
Frieda – Gold, geboren am 28.06.2024
|
Die Mama ist am 13.08.2025 geboren und trägt die Farbe Kohlfuchs. Sie ist eine eher ruhige und vorsichtige Rennmaus, die neuen Situationen zunächst aufmerksam begegnet. Gibt man ihr etwas Zeit, zeigt sich schnell ihre neugierige Seite und sie kommt gerne schauen, was im Gehege passiert. Sie ist ein echtes Energiebündel, liebt es zu buddeln, zu rennen und alles an Beschäftigungsmaterial – besonders Klorollen – kreativ umzugestalten. Charakterlich zeigt sie sich sehr sozial und friedlich; besonders hervorzuheben ist ihre fürsorgliche Art als Mutter.
|
||||||
Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern.
|
|
||||||
|
|
||||||
Fine – Agouti, geboren am 28.06.2024
|
Die Tochter ist am 26.02.2026 geboren und trägt die Farbe Agouti-Schecke. Sie ist ihrer Mama sehr ähnlich, dabei aber schon ein kleines bisschen mutiger. Auch sie beobachtet erst gerne in Ruhe, bevor die Neugier siegt. Sie ist sehr anhänglich und orientiert sich stark an ihrer Mutter.
|
||||||
Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team.
|
|
||||||
|
|
||||||
Die Schwestern ziehen selbstverständlich nur zusammen um.
|
Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
|
||||||
|
|||||||
207
HUMANQUESTION.md
207
HUMANQUESTION.md
@@ -2,150 +2,80 @@
|
|||||||
|
|
||||||
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
> ## ⛔ BITTE DIESE DATEI NICHT SELBST BEARBEITEN
|
||||||
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
> **Antwortet einfach Michael (dem Assistenten) im Chat** — Stichworte genügen
|
||||||
> („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
|
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei**
|
||||||
> **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
|
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur
|
||||||
> ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren —
|
> das, was **noch offen** ist. Nichts geht verloren.
|
||||||
> alles wartet geduldig, bis ihr antwortet.
|
|
||||||
|
|
||||||
_Stand: 2026-06-06._
|
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle
|
||||||
|
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
|
||||||
Der Manager ist fertig und läuft. Die alten Daten sind importiert
|
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit.
|
||||||
(**723 Würfe + 247 Tiere + 99 Fotos** sind drin). Mehrere KI-Funktionen sind
|
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler
|
||||||
**fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel hinterlegt ist.
|
Feinschliff** — siehe unten._
|
||||||
Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Für Julian — Konten, Schlüssel, Technik
|
## A. Zugänge & Technik (für Julian)
|
||||||
|
|
||||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
| # | Was gebraucht wird | Schaltet frei |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
| **A3** | **Gmail App-Passwort.** Google-Konto → 2-Faktor aktivieren → „App-Passwörter" → eines für „GerbilManager" → 16-stelligen Code an Michael. | E-Mail-Posteingang (Anfragen abrufen + KI-Antwortentwürfe). Backend ist fertig, wartet nur auf den Zugang. |
|
||||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
| **A4** | **Domain-Name** (registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite veröffentlichen (Jimdo-Ersatz). Seite ist gebaut. |
|
||||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
| **A5** | **TrueNAS-Restfragen:** (a) SCALE-Version? · (c) eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? · (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Produktiv-Betrieb auf dem NAS (compose ist fertig vorbereitet). |
|
||||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo → Einstellungen → Actions → Secrets): `REGISTRY_USER` (dein Gitea-Login) + `REGISTRY_TOKEN` (Token mit `write:package`). | CI pusht fertige Docker-Images in die Registry. (Die CI-Tests laufen bereits grün.) |
|
||||||
| A5 | **TrueNAS / Gitea — 4 Antworten:** (a) TrueNAS SCALE-Version? (Electric Eel 24.10+?) (b) Gitea Actions aktiviert/aktivierbar? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Welcher Dataset-Pfad für Daten/Backups, und ist Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose + CI sind fertig vorbereitet) |
|
|
||||||
|
|
||||||
## B. Für Julian — kleine Aktionen (jederzeit)
|
## B. Kleine Aktion (jederzeit)
|
||||||
|
|
||||||
| # | Aktion | Warum |
|
| # | Aktion | Warum |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| B1 | **`git pull` + App neu starten** auf der laufenden Instanz | Holt den **Such-Fix** + neue Felder (Herkunft-Filter, unscharfe Suche, Charakterbogen). Die laufende Instanz ist sonst auf altem Stand. |
|
| **B2** | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt (http://192.168.2.124:5173). |
|
||||||
| B2 | **Firewall-Regel** (PowerShell **als Administrator**): `New-NetFirewallRule -DisplayName "GerbilManager dev" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5173,5179 -Profile Any` | Damit das Handy deiner Frau im WLAN auf die App kommt. |
|
|
||||||
| B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
|
|
||||||
|
|
||||||
---
|
## C. Genetik-Feinschliff (optional, blockiert nichts)
|
||||||
|
|
||||||
## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
|
| # | Frage | Betrifft |
|
||||||
|
|
||||||
Diese Antworten laden die **restlichen importierten Daten** nach (die unten
|
|
||||||
gelisteten Konflikt-Tiere + Tiere mit Sonder-Kürzeln warten in Quarantäne —
|
|
||||||
**nichts ist verloren**; sobald geklärt, werden sie automatisch nachgeladen).
|
|
||||||
|
|
||||||
| # | Frage | Blockiert |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ~~C1~~ | ✅ **BEANTWORTET** (2026-06-06): `Uw`=`G` (internationale Schreibweise, groß=groß/klein=klein) · `Sls`=`WP` (eine Scheckungsart) · `DP`=Darkpatch (Scheckungsart) · `Dea`=hörend / `dea`=`taub` (Hörfähigkeit, hängt von der Scheckung ab, steht nach `spsp`) · `WFNZ`=Wildfangnachzucht (keine rezessiven Gene) · `RV`=Rückverpaarung · `GV`=Geschwisterverpaarung (RV/GV = Zuchtmethode, kein Gencode). → Michael baut das ein; **damit lösen sich die 5 Konflikte in Abschnitt D2 von selbst** (Gg ≡ Uwuw). | — erledigt |
|
| **REW-1** | **PEW = REW?** „Pink Eyed White" (PEW) und „Rotaugenweiß" (REW) sind dasselbe — soll der alte PEW-Eintrag mit REW **zusammengeführt** werden? | Doppel-Eintrag im Farbkatalog. |
|
||||||
| ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
|
| **REW-2** | **REW bei nicht-agouti?** Wird ein `aa`-Tier (z. B. Marder) mit Colourpoint + `pp` **auch** als REW gewertet, oder soll REW nur für agouti-basierte Tiere gelten? | REW-Erkennung (wird gerade A-unabhängig gebaut = aa zählt mit). |
|
||||||
| ~~C3~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
|
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
||||||
| ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
|
|
||||||
| ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef` → **Orangeschimmel** · `efef pp` → **Rotaugenschimmel** · `efef gg` → **Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef` → **CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
|
|
||||||
| C6 | **Die 32 Konflikt-Tiere prüfen** → siehe Abschnitt **D**. | diese 32 Tiere werden erst danach geladen |
|
|
||||||
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
|
|
||||||
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
|
|
||||||
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
|
|
||||||
|
|
||||||
### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
|
## E. Charakterbogen-Eigenschaften (optional)
|
||||||
„Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
|
|
||||||
|
|
||||||
---
|
Aktuell eingebaute Häkchen-Eigenschaften (für die KI-Verkaufstexte) — **soll etwas ergänzt/gestrichen werden?**
|
||||||
|
|
||||||
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
|
|
||||||
|
|
||||||
> ✅ **STAND nach Re-Import #2 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (+13 Tiere, +31 Würfe, +9 Fotos — u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Von 32 sind noch **8 in Quarantäne**; 3 davon (Enya, Ella, Zac) löst der Importer demnächst automatisch („genauer gewinnt": `CC` schlägt `C-` — gleiche Logik wie die Beibehalten-Regel). **Wirklich offen: nur die 5 Tiere in D6 unten.**
|
|
||||||
|
|
||||||
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
|
|
||||||
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
|
|
||||||
|
|
||||||
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
|
|
||||||
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
|
|
||||||
|
|
||||||
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|
|
||||||
|---|---|---|
|
|
||||||
| ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
|
|
||||||
| Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
|
|
||||||
| Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
|
|
||||||
| Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
|
|
||||||
| Trogir v.d. Kleinen Chaoten (*21.03.2022) | „Mahima…" (evtl. Gold Ansatzschecke) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Chayton v.d. Kleinen Chaoten (*04.02.2022) | „Victoria Welby…" (evtl. Orangeschimmel, hell) | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Zac gen. Action v.d. Kleinen Chaoten (*25.12.2020) | „Belica gen. Emi…" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids / Kohlief, Goldfuchsef Sp von Chrissi / Watarus Kids* |
|
|
||||||
| Chesnut (*13.11.2019) | „Tennessee…" (evtl. Kohlfuchsschimmel) | *Stammbaum von Kentucky* |
|
|
||||||
| Ethan v.d. Kleinen Chaoten (*09.07.2020) | „Ichika…" (evtl. Orangeschimmel hell Kragenschecke) | *(Quelle siehe `review-report.md`)* |
|
|
||||||
| Quied Soldier of Black Forest (*07.06.2018) | „Hoshi…" | *Stammbaum von Kentucky* |
|
|
||||||
|
|
||||||
### ~~D2~~ · ✅ GELÖST durch C1: `Uw`=`G` — diese 5 sind KEINE echten Konflikte, werden automatisch geladen, sobald Michael die Uw=G-Regel eingebaut hat
|
|
||||||
~~Ella · Roswitha · Silenos gen. Adonis · Brandon Stark · Enya~~ (erledigt)
|
|
||||||
|
|
||||||
### D3 · Genotyp: **kleine Abweichung** (eine Quelle genauer als die andere — `DD`↔`D-`, `PP`↔`P-`, `Ee`↔`E`, mit/ohne `spsp`) — welche stimmt?
|
|
||||||
Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich beim genannten Locus). Alle Varianten: `review-report.md`.
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Firefly v.d. K.C. (*18.12.2019) | D-Locus: **D-** ↔ **DD** | ✅ **D-** (DD war Tippfehler) — Julian |
|
|
||||||
| WildFire v.d. K.C. (*05.10.2017) | P-Locus: **P-** ↔ **PP** | ✅ **PP** — Julian |
|
|
||||||
| Zuleika v.d. K.C. (*24.10.2015) | D-Locus: **D-** ↔ **DD** | ✅ **DD, Ee, Gg, PP** (`aa c[chm]c[h] DD Ee Gg PP spsp`) — Julian |
|
|
||||||
| Milka of LennyLengo (*09.12.2018) | C-Locus: **C-** ↔ **Cc[h]** · E-Locus: **E-** ↔ **EE** | ✅ **Cc[h], EE** (`aa Cc[h] dd EE Gg P- Spsp`) — Julian |
|
|
||||||
| Silvain v.d. K.C. (*27.03.2022) | E-Locus: **Ee** ↔ **ee** · P-Locus: **P-** ↔ **Pp** | ✅ **ee, Pp** (`aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp`) — Julian |
|
|
||||||
| Ichika v.d. K.C. (*19.04.2020) | E-Locus: **ee** ↔ **ee[f]** | ✅ **ee[f]** (Beibehalten-Regel: `[f]` war vorhanden) — Julian |
|
|
||||||
| Daja of Little Rose (*16.05.2021) | Scheckung: **mit `spsp`** ↔ **ohne** | ✅ **mit `spsp`** (Beibehalten-Regel) — Julian |
|
|
||||||
| Chelsea v.d. K.C. | ⚠️ **Kein Genotyp-Konflikt** — zwei „Chelsea" mit verschiedenem Datum (\*02.04.2021 / \*15.10.2021) | ✅ **ein Tier, Geburtsdatum 02.04.2021** (15.10.2021 war falsch → zusammengeführt) — Julian |
|
|
||||||
|
|
||||||
### D4 · **Marker** unterschiedlich (`WP` / `DP` / `WFNZ` / „hörend" mal vorhanden, mal nicht) — welcher gilt?
|
|
||||||
> ✅ **REGEL (Julian 2026-06-06):** „Wenn irgendwo etwas vorhanden war, das anderswo fehlte → **immer beibehalten**." Gilt generell für Marker/Flags und Angaben wie `spsp` oder `[f]` (Vorhandensein gewinnt über Fehlen). Wird zur Standard-Regel im Importer → löst alle „mit/ohne"-Fälle automatisch (z. B. Daja `spsp`, Ichika `[f]`). Greift NICHT bei echten Wert-Widersprüchen (z. B. `DD`↔`D-`, `Ee`↔`ee`) — die brauchen weiter deine Entscheidung.
|
|
||||||
>
|
|
||||||
> ℹ️ Bei D4 waren die Marker `WP`/`DP`/`WFNZ`/`hörend` in BEIDEN Quellen gleich — also **gar nicht** der Konflikt (und jetzt sowieso Flags). Der echte Konflikt ist beim Genotyp. **3 von 5 dadurch automatisch gelöst:**
|
|
||||||
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? | Status |
|
|
||||||
|---|---|---|
|
|
||||||
| Vestra von den Schlossmäusen (*08.02.2019) | D-Locus: **D-** ↔ **DD** (WP gleich in beiden) | ✅ **DD** — Julian |
|
|
||||||
| Victoria Welby gen. Welby v.d. K.C. (*16.01.2023) | E-Locus: **Ee[f]** ↔ **ee[f]** — **Mutter von „C"!** | ✅ **ee[f]** — Julian → **geladen, C hat jetzt beide Eltern** (Re-Import #2) |
|
|
||||||
| Hedwig of BGB (*30.10.2019) | (WP/DP/hörend) | ✅ auto-gelöst — sind jetzt Flags, kein Konflikt mehr |
|
|
||||||
| Pitari gen. Piti v.d. K.C. (*16.05.2021) | (DP) | ✅ auto-gelöst — DP ist jetzt ein Flag |
|
|
||||||
| Little Hero of Black Forest (*22.02.2018) | (WFNZ ± spsp) | ✅ kein Genotyp-Konflikt mehr (WFNZ = Flag) |
|
|
||||||
|
|
||||||
### D5 · **Sterbedatum** widersprüchlich
|
|
||||||
| Tier | Problem |
|
|
||||||
|---|---|
|
|
||||||
| ~~Flint v.d. Kleinen Chaoten (*23.12.2017)~~ | ✅ **Tod 10.05.2021** (2022 war Tippfehler) — Julian 2026-06-06 |
|
|
||||||
| Hanami v.d. Kleinen Chaoten (*10.09.2015) | Tod 12.12.2019 vs. 14.01.2020 |
|
|
||||||
| ~~Molly of Black Forest (*13.09.2021)~~ | ✅ **Tod 03.05.2022** (03.05.2021 war Jahr-Tippfehler → lag vor der Geburt) — Julian 2026-06-06 |
|
|
||||||
|
|
||||||
*(Das sind 32 Tiere: 11 + 5 + 8 + 5 + 3.)*
|
|
||||||
|
|
||||||
### D6 · **Die letzten 5 offenen Konflikte** (Stand Re-Import #2) — bitte entscheiden
|
|
||||||
| Tier | Konkreter Konflikt — was stimmt? |
|
|
||||||
|---|---|
|
|
||||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
|
||||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
|
||||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
|
||||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
|
||||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
|
||||||
|
|
||||||
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
|
|
||||||
|
|
||||||
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
|
|
||||||
sagen, was ergänzt oder gestrichen werden soll:**
|
|
||||||
|
|
||||||
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
|
||||||
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
|
||||||
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
|
||||||
|
|
||||||
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
|
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch*?)
|
||||||
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
|
- **E2** — Auch ehrliche **negative** Eigenschaften aufnehmen (z. B. *beißt manchmal*) für ehrliche Inserate?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden)
|
||||||
|
|
||||||
|
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.)
|
||||||
|
|
||||||
|
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
|
||||||
|
| Tier | Sterbedatum — welches? |
|
||||||
|
|---|---|
|
||||||
|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 |
|
||||||
|
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 |
|
||||||
|
| Osamu v.d. K.C. (*10.12.2015) | 01.10.2020 ↔ 18.12.2020 |
|
||||||
|
| Filou v.d. K.C. (*24.11.2014) | 31.08.2019 ↔ 31.10.2019 |
|
||||||
|
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 |
|
||||||
|
|
||||||
|
**B) Gencode-Konflikt** (+ ggf. Sterbedatum):
|
||||||
|
| Tier | Konflikt — was stimmt? |
|
||||||
|
|---|---|
|
||||||
|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa** ↔ **aa** |
|
||||||
|
| Percy of little runners (*16.12.2017) | P-Locus: **PP** ↔ **Pp** |
|
||||||
|
| Iwana of little runners (*02.10.2018) | P-Locus: **PP** ↔ **Pp** |
|
||||||
|
| Sokrates v.d. K.C. (*14.12.2015) | D-Locus: **D-** ↔ **Dd** · + Sterbedatum 20.05.**2019** ↔ **2020** |
|
||||||
|
| Eragon (Elieus, *18.05.2016) | C-Locus: **CC** (vollfarbig) ↔ **c[chm]c[chm]** (Colourpoint) |
|
||||||
|
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** |
|
||||||
|
| Kazumi v.d. K.C. (*23.04.2013) | A: **Aa**↔**aa** · G: **GG**↔**Gg** · P: **PP**↔**Pp** · Sp: **Spsp**↔**spsp** |
|
||||||
|
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) |
|
||||||
|
|
||||||
|
*(Alle Gencode-Varianten + Quelldateien: `tools/import/output/review-report.md`.)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,16 +83,25 @@ sagen, was ergänzt oder gestrichen werden soll:**
|
|||||||
|
|
||||||
| Funktion | Status | Wartet auf |
|
| Funktion | Status | Wartet auf |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | ✅ **LIVE & kostenlos** (Gemini Free-Tier) | — |
|
||||||
| Charakterbogen → KI-Text | gebaut, schläft | **A1** |
|
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) |
|
||||||
| Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
|
| Öffentliche Webseite + KI-Chat-Editor | gebaut | **A4** (Domain + Cloudflare) |
|
||||||
| Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
|
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** |
|
||||||
| E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
|
|
||||||
| NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
|
|
||||||
| Restliche importierte Tiere (Konflikte) | nur noch 8 in Quarantäne (Re-Import #2 ✅) | **D6** (5 Entscheidungen; Enya/Ella/Zac lädt Michael automatisch nach) |
|
|
||||||
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
|
||||||
|
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein, streicht
|
## ✅ Erledigt (Archiv — nichts geht verloren)
|
||||||
sie hier und meldet, was dadurch live geht.*
|
|
||||||
|
- **A1 / A2 — Gemini-Key + Kosten:** Schlüssel hinterlegt (sicher, nie im Code); **kostenlos** über `gemini-flash-latest` (Free-Tier), kein Billing nötig. → KI live verifiziert.
|
||||||
|
- **B1 — Neustart nach Updates:** Michael startet die lokale Instanz jetzt **automatisch** nach jedem neuen Feature. Keine Aktion mehr nötig.
|
||||||
|
- **B3 — 2 Test-Einträge ("Testmaus/Wurf E2E"):** verschwinden automatisch beim finalen Re-Import (Wipe + Neuaufbau).
|
||||||
|
- **C1–C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
|
||||||
|
- **D1–D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
|
||||||
|
|
||||||
|
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager Produktionskonfiguration
|
# GerbilManager Produktionskonfiguration
|
||||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||||
|
|
||||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||||
@@ -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-flash-latest
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# GerbilManager — TrueNAS Custom Application
|
# GerbilManager — TrueNAS Custom Application
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# Vor dem ersten Start:
|
# Vor dem ersten Start:
|
||||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||||
@@ -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-flash-latest}"
|
||||||
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:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||||
|
|
||||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||||
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
|||||||
|
|
||||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
|
||||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||||
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
|||||||
```powershell
|
```powershell
|
||||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
$env:AI__ApiKey = '<schlüssel>'
|
$env:AI__ApiKey = '<schlüssel>'
|
||||||
$env:AI__Model = 'gemini-2.0-flash'
|
$env:AI__Model = 'gemini-flash-latest'
|
||||||
```
|
```
|
||||||
|
|
||||||
oder per user-secrets im API-Projekt:
|
oder per user-secrets im API-Projekt:
|
||||||
@@ -46,5 +46,5 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||||
AI__Model: gemini-2.0-flash
|
AI__Model: gemini-flash-latest
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ npm run build
|
|||||||
npm run preview
|
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)
|
## E2E-Tests (QA-1, Playwright)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
129
gerbil-manager-web/e2e/abgabe.spec.ts
Normal file
129
gerbil-manager-web/e2e/abgabe.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* QA-2: Abgabe-Wizard (FEAT-12a + FEAT-13) — Abgabetiere gruppieren,
|
||||||
|
* KI-Inserat generieren, und Vertrag-Handoff via ?tiere= Query-Parameter.
|
||||||
|
*
|
||||||
|
* Mock-gebunden (Seed: 2 ForSale-Tiere im Quarantänebecken) → skipUnlessMock.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const ta = de.pages.abgabe
|
||||||
|
const tw = de.pages.vertraege.wizard
|
||||||
|
|
||||||
|
test.describe('Abgabe', () => {
|
||||||
|
test('Abgabe-Seite zeigt ForSale-Tiere und Inserat-Vorschau', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.getByRole('heading', { name: ta.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText(ta.grouping.title)).toBeVisible()
|
||||||
|
|
||||||
|
// Die beiden ForSale-Tiere müssen als Tiername (strong-Element im Komposer) sichtbar sein
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Benny Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
// Inserat-Vorschau (listing-preview) wird gerendert
|
||||||
|
await expect(page.locator('.listing-preview').first()).toBeVisible()
|
||||||
|
// Status-Dropdown standard: FREI
|
||||||
|
await expect(
|
||||||
|
page.locator('label.field').filter({ has: page.locator(`span:text-is("${ta.listing.status}")`) }).locator('select').first()
|
||||||
|
).toHaveValue('free')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Inserat: "Text mit KI verbessern" — Mock liefert Inserat-Text', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: ta.ai.improve }).first().click()
|
||||||
|
// Mock antwortet synchron; der generierte Text erscheint in der Vorschau
|
||||||
|
await expect(page.locator('.listing-preview').first()).toContainText('Generierter Inserat-Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('KI-Inserat: 503 zeigt deutschen Hinweis (saleAdConfigured=false)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
mockDb!.saleAdConfigured = false
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: ta.ai.improve }).first().click()
|
||||||
|
await expect(page.getByText(ta.ai.notConfigured)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Vertrag-Handoff: "Abgabe abschließen" öffnet Wizard mit vorausgewählten Tieren', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.forSale)
|
||||||
|
await expect(page.locator('.group-composer__animal-head strong').filter({ hasText: 'Balu Abgabe' })).toBeVisible()
|
||||||
|
|
||||||
|
// Wizard öffnen (navigiert zu /vertraege/neu?tiere=sale-balu,sale-benny)
|
||||||
|
await page.getByRole('button', { name: ta.export.finish }).first().click()
|
||||||
|
await expect(page).toHaveURL(/\/vertraege\/neu\?tiere=/)
|
||||||
|
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
|
||||||
|
// Schritt 1: Abnehmer auswählen
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Vertrag-Wizard', () => {
|
||||||
|
test('4-Schritte-Wizard: Abnehmer → Tiere → Preis → Vertrag erzeugen', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Direkt mit vorausgewählten Tieren öffnen (wie über Abgabe-Seite)
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu,sale-benny')
|
||||||
|
await expect(page.getByRole('heading', { name: tw.title })).toBeVisible()
|
||||||
|
|
||||||
|
// Schritt 1: Abnehmer wählen — Zoohandlung Meier aus dem Seed
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 2: Tiere — Vorauswahl aus ?tiere= ist aktiv
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
|
||||||
|
// Beide ForSale-Tiere sind vorausgewählt (Checkboxen angehakt)
|
||||||
|
await expect(
|
||||||
|
page.locator('.wizard-pick').filter({ hasText: 'Balu Abgabe' }).getByRole('checkbox')
|
||||||
|
).toBeChecked()
|
||||||
|
await expect(
|
||||||
|
page.locator('.wizard-pick').filter({ hasText: 'Benny Abgabe' }).getByRole('checkbox')
|
||||||
|
).toBeChecked()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 3: Preis & Datum
|
||||||
|
await page.getByLabel(tw.priceLabel).fill('50,00')
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
|
||||||
|
// Schritt 4: Zusammenfassung + Erzeugen
|
||||||
|
await expect(page.getByText(tw.summaryTitle)).toBeVisible()
|
||||||
|
await expect(page.getByText('Zoohandlung Meier')).toBeVisible()
|
||||||
|
await expect(page.getByText('Balu Abgabe')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.generate }).click()
|
||||||
|
|
||||||
|
// Erfolg: Vertrag erstellt, Download-Link vorhanden
|
||||||
|
await expect(page.getByRole('heading', { name: tw.successTitle })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: tw.downloadDocx })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schritt 1: fehlender Abnehmer zeigt Validierungsfehler', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu')
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickContact })).toBeVisible()
|
||||||
|
// Ohne Auswahl direkt auf Weiter klicken → Fehler-Alert erscheint
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Alert enthält den Hinweis (stepError), Schritt bleibt auf 1
|
||||||
|
await expect(page.locator('.alert--error').filter({ hasText: tw.pickContact })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schritt 3: ungültiger Preis zeigt Validierungsmeldung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/vertraege/neu?tiere=sale-balu')
|
||||||
|
// Schritt 1: Abnehmer wählen
|
||||||
|
await page.locator('.wizard-pick').filter({ hasText: 'Zoohandlung Meier' }).getByRole('radio').check()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Schritt 2: Tiere bestätigen
|
||||||
|
await expect(page.getByRole('heading', { name: tw.pickAnimals })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
// Schritt 3: leeres Preisfeld -> Fehler
|
||||||
|
await expect(page.getByLabel(tw.priceLabel)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tw.next }).click()
|
||||||
|
await expect(page.locator('.alert--error').filter({ hasText: tw.priceInvalid })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
|
||||||
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
|
||||||
*/
|
*/
|
||||||
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const ta = de.pages.anfragen
|
const ta = de.pages.anfragen
|
||||||
const td = ta.detail
|
const td = ta.detail
|
||||||
@@ -23,6 +23,8 @@ test.describe('Anfragen', () => {
|
|||||||
// Status-Badge auf der Karte
|
// Status-Badge auf der Karte
|
||||||
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Filter: nur Beantwortet
|
// Filter: nur Beantwortet
|
||||||
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
await page.getByLabel(td.statusLabel).selectOption('Answered')
|
||||||
await expect(cards).toHaveCount(1)
|
await expect(cards).toHaveCount(1)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const tk = de.pages.kontakte
|
|||||||
test.describe('Becken', () => {
|
test.describe('Becken', () => {
|
||||||
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/becken')
|
await page.goto('/gehege')
|
||||||
await page.getByRole('link', { name: /Großbecken/ }).click()
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
||||||
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
|
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
|
||||||
@@ -18,7 +18,7 @@ test.describe('Becken', () => {
|
|||||||
|
|
||||||
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/becken')
|
await page.goto('/gehege')
|
||||||
await page.getByRole('link', { name: /Großbecken/ }).click()
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
acceptNextDialog(page)
|
acceptNextDialog(page)
|
||||||
await page.getByRole('button', { name: tb.delete.action }).click()
|
await page.getByRole('button', { name: tb.delete.action }).click()
|
||||||
@@ -72,7 +72,13 @@ test.describe('Kontakte', () => {
|
|||||||
|
|
||||||
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
||||||
await gotoSection(page, de.nav.contacts)
|
await gotoSection(page, de.nav.contacts)
|
||||||
|
// Explizit auf die Listenüberschrift warten, bevor wir klicken — verhindert den
|
||||||
|
// CI-Flake, der auftrat wenn der Vite-Server unter paralleler Last noch nicht
|
||||||
|
// alle Routen der Seite gerendert hatte (gotoSection klickt nur den Nav-Link,
|
||||||
|
// wartet aber nicht auf den vollständigen Seitenaufbau).
|
||||||
|
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
|
||||||
await page.getByRole('link', { name: tk.newButton }).click()
|
await page.getByRole('link', { name: tk.newButton }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: tk.form.createTitle })).toBeVisible()
|
||||||
const name = uniqueName('Kontakt')
|
const name = uniqueName('Kontakt')
|
||||||
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
||||||
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
// FEAT-13: strukturierte Felder statt Freitext-Kontaktdaten
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
@@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
|
|||||||
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
|
||||||
|
|
||||||
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })
|
||||||
|
|||||||
@@ -17,5 +17,10 @@ test('Charakterbogen: Eigenschaft umschalten, speichern und im Abgabe-Komposer w
|
|||||||
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
// Tier zur Abgabe stellen -> erscheint im /abgabe-Komposer mit gesetzter Eigenschaft.
|
||||||
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
await page.getByRole('button', { name: de.pages.abgabe.markAction }).click()
|
||||||
await page.goto('/abgabe')
|
await page.goto('/abgabe')
|
||||||
await expect(page.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
// Auf Krümels Tier-Abschnitt eingrenzen — andere ForSale-Tiere haben ebenfalls
|
||||||
|
// Charakterbögen, deshalb darf die Assertion nicht seitenbreit nach 'neugierig' suchen.
|
||||||
|
const kruemelSection = page.locator('.group-composer__animal').filter({
|
||||||
|
has: page.locator('strong', { hasText: 'Krümel' }),
|
||||||
|
})
|
||||||
|
await expect(kruemelSection.getByRole('checkbox', { name: 'neugierig' })).toBeChecked()
|
||||||
})
|
})
|
||||||
|
|||||||
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
85
gerbil-manager-web/e2e/filter-panel.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
|
||||||
|
*
|
||||||
|
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
|
||||||
|
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
|
||||||
|
test.describe('FilterPanel – Rennmäuse-Liste', () => {
|
||||||
|
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Toggle-Button sichtbar.
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
|
||||||
|
// Status-Beschriftung im Drawer ist noch verborgen.
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
|
||||||
|
// Öffnen → Status-Feld wird sichtbar.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Schließen → wieder verborgen.
|
||||||
|
await toggle.click()
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Phone: Badge zählt aktive Filter korrekt', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
|
||||||
|
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
|
||||||
|
await expect(toggle).toHaveText('Filter')
|
||||||
|
|
||||||
|
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
|
||||||
|
await toggle.click()
|
||||||
|
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
|
||||||
|
await expect(toggle).toHaveText('Filter (1)')
|
||||||
|
|
||||||
|
// Zurücksetzen → Badge weg.
|
||||||
|
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
|
||||||
|
await expect(toggle).toHaveText('Filter')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'desktop') return
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Kein Toggle-Button auf Desktop (display:none via Media Query).
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
await expect(toggle).toBeHidden()
|
||||||
|
|
||||||
|
// Status-Beschriftung direkt sichtbar.
|
||||||
|
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,12 +39,23 @@ export function skipUnlessMock() {
|
|||||||
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test überspringen im Mock-Modus — nur im LIVE-Modus sinnvoll.
|
||||||
|
* Live-Tests MÜSSEN Ergebnisse assertieren (z. B. Liste nicht leer,
|
||||||
|
* konkreter Wert sichtbar), nicht nur HTTP-200. Lesson: der Mock spiegelt
|
||||||
|
* den Frontend-Code, nicht das echte Backend — ein Bug bleibt unsichtbar,
|
||||||
|
* wenn wir nur testen, ob die Seite lädt.
|
||||||
|
*/
|
||||||
|
export function skipUnlessLive() {
|
||||||
|
test.skip(!LIVE, 'nur im LIVE-Modus sinnvoll (E2E_BASE_URL setzen)')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
||||||
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||||
*/
|
*/
|
||||||
export async function gotoSection(page: Page, label: string) {
|
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
|
// App noch nicht geladen (about:blank) -> erst zur Startseite
|
||||||
if (!(await nav.isVisible())) {
|
if (!(await nav.isVisible())) {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@@ -63,6 +74,18 @@ export function acceptNextDialog(page: Page) {
|
|||||||
page.once('dialog', (d) => void d.accept())
|
page.once('dialog', (d) => void d.accept())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
|
||||||
|
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
|
||||||
|
* kein Klick nötig — Controls sind direkt sichtbar.
|
||||||
|
*/
|
||||||
|
export async function openFilterPanel(page: Page) {
|
||||||
|
const toggle = page.locator('.filter-panel__toggle')
|
||||||
|
if (await toggle.isVisible()) {
|
||||||
|
await toggle.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||||
export const uniqueName = (prefix: string) =>
|
export const uniqueName = (prefix: string) =>
|
||||||
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
|
|||||||
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$/)
|
||||||
|
})
|
||||||
54
gerbil-manager-web/e2e/genotype-table.spec.ts
Normal file
54
gerbil-manager-web/e2e/genotype-table.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* UX-MOBILE-2: Genotyp-Tabellen-Overflow — die Tabelle scrollt horizontal,
|
||||||
|
* die Seite selbst bleibt NICHT breiter als der Viewport (kein horizontaler
|
||||||
|
* Page-Overflow auf 390px).
|
||||||
|
*/
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const tl = de.pages.litters
|
||||||
|
|
||||||
|
test('Phone: Genotyp-Detail-Tabelle scrollt im eigenen Wrapper, kein Page-Overflow', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'phone') return
|
||||||
|
|
||||||
|
// Wurf w-kruemel hat beide Eltern mit Genotypen → BreedingResultView rendert.
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
|
||||||
|
|
||||||
|
// Genotyp-Detail-Tabelle ausklappen.
|
||||||
|
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
|
||||||
|
|
||||||
|
// Scroll-Wrapper und Tabelle müssen vorhanden und sichtbar sein.
|
||||||
|
const wrapper = page.locator('.genotype-table-scroll').first()
|
||||||
|
await expect(wrapper).toBeVisible()
|
||||||
|
await expect(wrapper.locator('.genotype-table')).toBeVisible()
|
||||||
|
|
||||||
|
// Wrapper ist selbst scrollbar (Tabelle breiter als der sichtbare Bereich).
|
||||||
|
const wrapperScrollable = await wrapper.evaluate(
|
||||||
|
(el) => el.scrollWidth > el.clientWidth,
|
||||||
|
)
|
||||||
|
expect(wrapperScrollable).toBe(true)
|
||||||
|
|
||||||
|
// Die Seite selbst darf NICHT breiter als der Viewport sein.
|
||||||
|
const pageOverflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||||
|
)
|
||||||
|
expect(pageOverflow).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Desktop: Genotyp-Detail-Tabelle zeigt Wrapper auch auf breitem Viewport', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (testInfo.project.name !== 'desktop') return
|
||||||
|
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByText(tl.detail.expectedColors).first()).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: de.pages.genetik.showGenotypes }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('.genotype-table-scroll').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.genotype-table').first()).toBeVisible()
|
||||||
|
})
|
||||||
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)
|
||||||
|
})
|
||||||
52
gerbil-manager-web/e2e/live.spec.ts
Normal file
52
gerbil-manager-web/e2e/live.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* QA-2: Live-Modus-Smoke — läuft NUR gegen das echte Backend (E2E_BASE_URL).
|
||||||
|
*
|
||||||
|
* Lektion (aus dem Such-Bug): Mock-Tests spiegeln nur den Frontend-Code.
|
||||||
|
* Ein Backend-Bug (falscher Filter, leere Antwort, fehlende Migration) bleibt
|
||||||
|
* unsichtbar, wenn Live-Tests nur "Seite lädt" prüfen. Diese Tests assertieren
|
||||||
|
* ERGEBNISSE: Liste nicht leer, Suche liefert Treffer, Detail zeigt Stammdaten
|
||||||
|
* und Eltern-Links.
|
||||||
|
*
|
||||||
|
* Voraussetzung für den Eltern-Link-Test: Re-Import #2 durchgeführt
|
||||||
|
* (322 Tiere, 882 Würfe live; mindestens ein Wurf hat beide Eltern).
|
||||||
|
*/
|
||||||
|
import { de, expect, gotoSection, skipUnlessLive, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
const tl = de.pages.litters
|
||||||
|
|
||||||
|
test('Live: Tiere-Liste enthält nach Re-Import mindestens 10 Einträge', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
// Leere Liste = Backend-Bug (falscher Filter, fehlende DB-Migration o. ä.)
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live: Namenssuche gibt Treffer zurück — nicht nur HTTP 200', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||||
|
// Kurzer Prefix: bei 322 Tieren mindestens 1 Treffer. Kein Treffer = Gridify-Bug.
|
||||||
|
await page.getByPlaceholder(t.searchPlaceholder).first().fill('a')
|
||||||
|
const rows = page.locator('.gerbil-row')
|
||||||
|
await expect(rows.first()).toBeVisible({ timeout: 10_000 })
|
||||||
|
expect(await rows.count()).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Live: Wurf-Detail zeigt Elterntier-Links (mindestens Vater oder Mutter)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// 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')
|
||||||
|
|
||||||
|
// 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 })
|
||||||
|
})
|
||||||
@@ -101,6 +101,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
||||||
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
||||||
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
||||||
|
contracts: collection(db.contracts as unknown as Row[], 'contract'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = async (route: Route) => {
|
const handler = async (route: Route) => {
|
||||||
@@ -205,6 +206,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 405)
|
return json(route, 405)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABGABE: KI-Inserat-Generator (POST /gerbils/sale-ad) — vor der generischen Route
|
||||||
|
if (path === '/gerbils/sale-ad' && method === 'POST') {
|
||||||
|
if (!db.saleAdConfigured) {
|
||||||
|
return json(route, 503, { code: 'AiKeyMissing', message: 'AI nicht konfiguriert' })
|
||||||
|
}
|
||||||
|
return json(route, 200, { text: 'Status: FREI\n\n„Zwei Freunde suchen ein Zuhause" – Generierter Inserat-Text für den Mock.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ABGABE: Vertrag-Download — /contracts/{id}/file (3 Segmente, nicht vom Generic-Handler bedient)
|
||||||
|
const cm = path.match(/^\/contracts\/([^/]+)\/file$/)
|
||||||
|
if (cm && method === 'GET') {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
headers: { 'Content-Disposition': `attachment; filename="vertrag-${cm[1]}.docx"` },
|
||||||
|
body: Buffer.from([0x50, 0x4b, 0x05, 0x06, ...new Array(18).fill(0)]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
// SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der
|
||||||
// generischen /gerbils/:id-Route abfangen.
|
// generischen /gerbils/:id-Route abfangen.
|
||||||
if (path === '/gerbils/breeders' && method === 'GET') {
|
if (path === '/gerbils/breeders' && method === 'GET') {
|
||||||
@@ -313,6 +333,54 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ABGABE: POST /contracts — baut url-Feld und markiert Tiere als Abgegeben
|
||||||
|
if (path === '/contracts' && method === 'POST') {
|
||||||
|
const body = request.postDataJSON() as { contactId: string; gerbilIds: string[]; price: number; handoverDate: string; contractDate?: string | null }
|
||||||
|
const id = newId('contract')
|
||||||
|
const contract = {
|
||||||
|
id,
|
||||||
|
contactId: body.contactId,
|
||||||
|
gerbilIds: body.gerbilIds ?? [],
|
||||||
|
price: body.price,
|
||||||
|
handoverDate: body.handoverDate,
|
||||||
|
contractDate: body.contractDate ?? body.handoverDate,
|
||||||
|
fileName: `vertrag-${id}.docx`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
url: `/contracts/${id}/file`,
|
||||||
|
}
|
||||||
|
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
|
||||||
|
// Tiere auf GivenAway setzen (wie das echte Backend)
|
||||||
|
for (const gid of body.gerbilIds ?? []) {
|
||||||
|
const g = db.gerbils.find((x) => x.id === gid)
|
||||||
|
if (g) {
|
||||||
|
g.status = 'GivenAway'
|
||||||
|
g.receiverContactId = body.contactId
|
||||||
|
g.goHomeDate = body.handoverDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ export interface MockPage {
|
|||||||
blocks: MockBlock[]
|
blocks: MockBlock[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ABGABE: Abgabevertrag (mirrors SaleContract in src/api/contracts.ts). */
|
||||||
|
export interface MockContract {
|
||||||
|
id: string
|
||||||
|
contactId: string
|
||||||
|
price: number
|
||||||
|
handoverDate: string
|
||||||
|
contractDate: string
|
||||||
|
fileName: string
|
||||||
|
createdAt: string
|
||||||
|
gerbilIds: string[]
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MockDb {
|
export interface MockDb {
|
||||||
gerbils: Gerbil[]
|
gerbils: Gerbil[]
|
||||||
litters: Litter[]
|
litters: Litter[]
|
||||||
@@ -58,6 +71,11 @@ export interface MockDb {
|
|||||||
requests: InboxRequest[]
|
requests: InboxRequest[]
|
||||||
aiConfigured: boolean
|
aiConfigured: boolean
|
||||||
mailConfigured: boolean
|
mailConfigured: boolean
|
||||||
|
// ABGABE: Verträge + KI-Inserat-Flag
|
||||||
|
contracts: MockContract[]
|
||||||
|
saleAdConfigured: boolean
|
||||||
|
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||||
|
namesConfigured: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -119,6 +137,22 @@ export function seedDb(): MockDb {
|
|||||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||||
|
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||||
|
// mit dem Becken-Belegungstest der Großbecken-2-Tiere-Assertion).
|
||||||
|
{
|
||||||
|
...gerbil('sale-balu', 'Balu Abgabe', 'male', '2024-03-12', null, 'cv-agouti'),
|
||||||
|
status: 'ForSale' as const,
|
||||||
|
enclosureId: 'enc-leer',
|
||||||
|
notes: 'ruhig, nimmt Leckerlis aus der Hand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...gerbil('sale-benny', 'Benny Abgabe', 'male', '2024-03-12', null, 'cv-schwarz-schecke'),
|
||||||
|
status: 'ForSale' as const,
|
||||||
|
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[] = [
|
const litters: Litter[] = [
|
||||||
@@ -258,5 +292,8 @@ export function seedDb(): MockDb {
|
|||||||
requests,
|
requests,
|
||||||
aiConfigured: true,
|
aiConfigured: true,
|
||||||
mailConfigured: true,
|
mailConfigured: true,
|
||||||
|
contracts: [],
|
||||||
|
saleAdConfigured: true,
|
||||||
|
namesConfigured: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
59
gerbil-manager-web/e2e/namegen.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.namegen
|
||||||
|
const tf = de.pages.gerbils.form
|
||||||
|
|
||||||
|
test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Panel öffnen
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeVisible()
|
||||||
|
|
||||||
|
// Vorschläge laden
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
// Fenrir ist im Mock immer dabei (kein Buchstabe-Filter)
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf Vorschlag 'Fenrir'
|
||||||
|
await page.getByRole('button', { name: 'Fenrir' }).click()
|
||||||
|
|
||||||
|
// Panel geschlossen, Name-Feld befüllt
|
||||||
|
await expect(page.getByText(t.panelTitle)).toBeHidden()
|
||||||
|
await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByLabel(t.letterLabel).fill('F')
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
|
||||||
|
// Mock gibt nur Namen mit F zurück: Fenrir + Freya
|
||||||
|
await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible()
|
||||||
|
// Artemis (A) nicht sichtbar
|
||||||
|
await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
if (mockDb) mockDb.namesConfigured = false
|
||||||
|
await page.goto('/rennmaeuse/neu')
|
||||||
|
await page.getByRole('button', { name: t.button }).click()
|
||||||
|
await page.getByRole('button', { name: t.loadButton }).click()
|
||||||
|
await expect(page.getByText(t.keyMissing)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import { de, expect, gotoSection, test } from './fixtures'
|
|||||||
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
||||||
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
@@ -30,5 +30,5 @@ test('unbekannte Route zeigt die deutsche 404-Seite', async ({ page }) => {
|
|||||||
await page.goto('/diese-seite-gibt-es-nicht')
|
await page.goto('/diese-seite-gibt-es-nicht')
|
||||||
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||||
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
|
||||||
|
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
|
||||||
|
*
|
||||||
|
* Abgedeckte Änderungen:
|
||||||
|
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
|
||||||
|
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
|
||||||
|
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
|
||||||
|
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
|
||||||
|
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
|
||||||
|
*
|
||||||
|
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
|
||||||
|
*/
|
||||||
|
import { de, expect, skipUnlessLive, test } from './fixtures'
|
||||||
|
|
||||||
|
const API = 'http://localhost:5179'
|
||||||
|
|
||||||
|
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
|
||||||
|
// eef = e/ef het → E>e>ef Rank → ee[f]
|
||||||
|
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
|
||||||
|
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
|
||||||
|
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
|
||||||
|
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||||
|
// Rohes 'eef' darf nicht mehr im Code-Block stehen
|
||||||
|
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
|
||||||
|
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
|
||||||
|
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
|
||||||
|
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
|
||||||
|
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
|
||||||
|
// Ccchm → Cc[chm]; eef → ee[f]
|
||||||
|
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
|
||||||
|
const genoCode = page.locator('code')
|
||||||
|
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
|
||||||
|
// Rohes Storage-Format darf nicht sichtbar sein
|
||||||
|
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
|
||||||
|
|
||||||
|
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
|
||||||
|
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
|
||||||
|
|
||||||
|
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
|
||||||
|
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
|
||||||
|
await expect(originInput).toBeVisible()
|
||||||
|
await expect(originInput).toHaveValue('Kleinen Chaoten')
|
||||||
|
|
||||||
|
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
|
||||||
|
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
|
||||||
|
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
|
||||||
|
await expect(deafLabel).toBeVisible()
|
||||||
|
// Dropdown-Select im Label-Kontext
|
||||||
|
const deafSelect = deafLabel.locator('select')
|
||||||
|
await expect(deafSelect).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
|
||||||
|
|
||||||
|
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Snapshot VORHER
|
||||||
|
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||||
|
const snap = await before.json()
|
||||||
|
expect(snap.originBreeder).toBe('Kleinen Chaoten')
|
||||||
|
expect(snap.genotype).toBeTruthy()
|
||||||
|
|
||||||
|
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
|
||||||
|
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
|
||||||
|
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
|
||||||
|
data: {
|
||||||
|
name: snap.name,
|
||||||
|
gender: snap.gender,
|
||||||
|
status: snap.status,
|
||||||
|
isResident: snap.isResident,
|
||||||
|
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
|
||||||
|
expect(res.status()).toBe(204)
|
||||||
|
|
||||||
|
// Re-Fetch und vergleichen
|
||||||
|
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||||
|
const updated = await afterRes.json()
|
||||||
|
|
||||||
|
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
|
||||||
|
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
|
||||||
|
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
|
||||||
|
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
|
||||||
|
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
|
||||||
|
|
||||||
|
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
|
||||||
|
|
||||||
|
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
|
||||||
|
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
|
||||||
|
const placeholder = page.getByText('(ohne Namen)')
|
||||||
|
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
|
||||||
|
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
||||||
|
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
|
||||||
|
if (await toggle.isVisible()) await toggle.click()
|
||||||
|
}
|
||||||
|
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
// Stammbaum eines Tiers mit namenlosem Elternteil
|
||||||
|
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
|
||||||
|
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
|
||||||
|
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||||
|
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||||
|
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
|
||||||
|
const placeholder = page.getByText('(ohne Namen)')
|
||||||
|
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
|
||||||
|
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
|
||||||
|
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||||
|
if (visible) {
|
||||||
|
await expect(placeholder.first()).toBeVisible()
|
||||||
|
}
|
||||||
|
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
|
||||||
|
|
||||||
|
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/becken')
|
||||||
|
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
|
||||||
|
skipUnlessLive()
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
@@ -53,3 +53,60 @@ 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('Ahnen-Namen sind Links zur Tier-Detailseite (STAMMBAUM-OPEN)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Fridolin ist Vater von Krümel — sein Name-Link soll /rennmaeuse/fridolin öffnen
|
||||||
|
const nameLink = page.getByRole('link', { name: 'Fridolin' })
|
||||||
|
await expect(nameLink).toBeVisible()
|
||||||
|
// href korrekt gesetzt
|
||||||
|
await expect(nameLink).toHaveAttribute('href', '/rennmaeuse/fridolin')
|
||||||
|
// Tastatur-Navigation (umgeht Toolbar-Überlappung im SVG-Canvas, testet zugl. Accessibility)
|
||||||
|
await nameLink.focus()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/rennmaeuse\/fridolin$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/nameless-stub/stammbaum')
|
||||||
|
// Titel zeigt '(ohne Namen)'
|
||||||
|
await expect(page.getByRole('heading', { name: t.titleFor(de.pages.gerbils.nameless) })).toBeVisible()
|
||||||
|
// Karte selbst zeigt '(ohne Namen)' statt leer
|
||||||
|
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Mini-Legende zeigt alle drei Interaktionshinweise (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const hints = page.locator('.stammbaum-hints')
|
||||||
|
await expect(hints).toBeVisible()
|
||||||
|
await expect(hints).toContainText(t.tapHint)
|
||||||
|
await expect(hints).toContainText(t.hintName)
|
||||||
|
await expect(hints).toContainText(t.hintExpand)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Einpassen, damit die 4. Generation (Emil mit +) im Viewport liegt.
|
||||||
|
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||||
|
if (await fit.isVisible()) await fit.click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
|
||||||
|
// +-Button muss an der Tiefengrenze erscheinen (Emil hat litterId w-emil).
|
||||||
|
const expandBtn = page.getByRole('button', { name: t.expand }).first()
|
||||||
|
await expect(expandBtn).toBeVisible()
|
||||||
|
|
||||||
|
// Klick auf +: Emil wird aufgeklappt → Max (sein Vater) taucht als Link auf.
|
||||||
|
await expandBtn.click({ force: true })
|
||||||
|
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||||
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
const t = de.pages.gerbils
|
const t = de.pages.gerbils
|
||||||
const tabs = de.pages.tierTabs
|
const tabs = de.pages.tierTabs
|
||||||
@@ -23,6 +23,8 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
|
|||||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||||
|
|
||||||
|
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
|
||||||
|
await openFilterPanel(page)
|
||||||
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
|
||||||
await page
|
await page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||||
@@ -96,6 +98,48 @@ test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', as
|
|||||||
await expect(page.getByText(/85\s*g/)).toBeVisible()
|
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 }) => {
|
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
@@ -105,3 +149,50 @@ test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
await expect(page.getByText('Sehr zutraulich.')).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')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||||
|
await page.goto('/rennmaeuse/fridolin')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||||
|
// Wurf K als Link sichtbar
|
||||||
|
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||||
|
await expect(litterLink).toBeVisible()
|
||||||
|
// Vater-Badge sichtbar
|
||||||
|
await expect(page.getByText(t.detail.parentLittersRoleVater)).toBeVisible()
|
||||||
|
// Navigation zum Wurf funktioniert
|
||||||
|
await litterLink.first().click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite: Keine Würfe als Elternteil zeigt Leer-Zustand (ANIMAL-LITTERS)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Krümel hat keine Würfe als Elternteil (nur als Kind von w-kruemel)
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByText(t.detail.parentLittersTitle)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parentLittersEmpty)).toBeVisible()
|
||||||
|
})
|
||||||
|
|||||||
49
gerbil-manager-web/gen-seed.mts
Normal file
49
gerbil-manager-web/gen-seed.mts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Katalog-Generator — AR-5
|
||||||
|
*
|
||||||
|
* Erzeugt zwei Artefakte aus catalog.ts (Single Source of Truth):
|
||||||
|
*
|
||||||
|
* colorVarietySeed.generated.json — Display-Notation (Klammer: e[f]/c[chm]/c[h])
|
||||||
|
* → Quelle für UI-Dropdowns, Frontend-Suche.
|
||||||
|
*
|
||||||
|
* colorVarietySeed.backend.json — Frozen internal symbols (ef/cchm/ch)
|
||||||
|
* → Quelle für künftige EF-Seed-Migrationen (Pam).
|
||||||
|
* NICHT in Bracket-Notation ändern — Backend-Parser
|
||||||
|
* erwartet frozen symbols (CR-11-Matcher-Guardrail).
|
||||||
|
*
|
||||||
|
* Ausführen nach jeder Änderung an catalog.ts:
|
||||||
|
* npm run gen:catalog
|
||||||
|
*
|
||||||
|
* Der vitest-Drift-Guard (catalog-drift.test.ts) schlägt fehl, wenn
|
||||||
|
* generated.json veraltet ist — Fehler macht den fehlenden Generator-Lauf sichtbar.
|
||||||
|
*/
|
||||||
|
import { BASE_COLORS, CATALOG, representativeGenotype } from './src/genetics/catalog.ts'
|
||||||
|
import { LOCUS_ORDER } from './src/genetics/loci.ts'
|
||||||
|
import type { Genotype } from './src/genetics/genotype.ts'
|
||||||
|
import { writeFileSync } from 'fs'
|
||||||
|
|
||||||
|
/** Internal (frozen) display string — concatenates canonical allele symbols without bracket mapping. */
|
||||||
|
function toInternalString(g: Genotype): string {
|
||||||
|
return LOCUS_ORDER.filter(
|
||||||
|
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||||
|
)
|
||||||
|
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display artefact (bracket notation) ─────────────────────────────────────
|
||||||
|
const displayPath = './src/genetics/colorVarietySeed.generated.json'
|
||||||
|
writeFileSync(displayPath, JSON.stringify(CATALOG, null, 2) + '\n')
|
||||||
|
console.log(`[gen:catalog] display → ${displayPath} (${CATALOG.length} rows)`)
|
||||||
|
|
||||||
|
// ── Backend artefact (frozen internal symbols) ───────────────────────────────
|
||||||
|
const backendSeed = BASE_COLORS.map((entry, i) => ({
|
||||||
|
name: entry.name,
|
||||||
|
...(entry.english !== undefined ? { english: entry.english } : {}),
|
||||||
|
canonicalGenotype: toInternalString(representativeGenotype(entry)),
|
||||||
|
sortOrder: i,
|
||||||
|
...(entry.image !== undefined ? { image: entry.image } : {}),
|
||||||
|
}))
|
||||||
|
const backendPath = './src/genetics/colorVarietySeed.backend.json'
|
||||||
|
writeFileSync(backendPath, JSON.stringify(backendSeed, null, 2) + '\n')
|
||||||
|
console.log(`[gen:catalog] backend → ${backendPath} (${backendSeed.length} rows)`)
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test",
|
||||||
|
"gen:catalog": "npx tsx gen-seed.mts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom'
|
||||||
import AppShell from './components/AppShell'
|
import AppShell from './components/AppShell'
|
||||||
import BeckenPage from './pages/BeckenPage'
|
import BeckenPage from './pages/BeckenPage'
|
||||||
import BeckenDetailPage from './pages/BeckenDetailPage'
|
import BeckenDetailPage from './pages/BeckenDetailPage'
|
||||||
@@ -8,7 +8,6 @@ import GerbilDetailPage from './pages/GerbilDetailPage'
|
|||||||
import GerbilFormPage from './pages/GerbilFormPage'
|
import GerbilFormPage from './pages/GerbilFormPage'
|
||||||
import GenetikPage from './pages/GenetikPage'
|
import GenetikPage from './pages/GenetikPage'
|
||||||
import AbgabePage from './pages/AbgabePage'
|
import AbgabePage from './pages/AbgabePage'
|
||||||
import HomePage from './pages/HomePage'
|
|
||||||
import KontaktePage from './pages/KontaktePage'
|
import KontaktePage from './pages/KontaktePage'
|
||||||
import KontaktDetailPage from './pages/KontaktDetailPage'
|
import KontaktDetailPage from './pages/KontaktDetailPage'
|
||||||
import KontaktFormPage from './pages/KontaktFormPage'
|
import KontaktFormPage from './pages/KontaktFormPage'
|
||||||
@@ -28,12 +27,17 @@ import WebseiteVorschauPage from './pages/WebseiteVorschauPage'
|
|||||||
import AnfragenPage from './pages/AnfragenPage'
|
import AnfragenPage from './pages/AnfragenPage'
|
||||||
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
import AnfrageDetailPage from './pages/AnfrageDetailPage'
|
||||||
|
|
||||||
|
function BeckenRedirect() {
|
||||||
|
const { '*': splat } = useParams()
|
||||||
|
return <Navigate to={`/gehege${splat ? `/${splat}` : ''}`} replace />
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<Navigate to="/rennmaeuse" replace />} />
|
||||||
<Route path="rennmaeuse">
|
<Route path="rennmaeuse">
|
||||||
<Route index element={<GerbilsPage />} />
|
<Route index element={<GerbilsPage />} />
|
||||||
<Route path="neu" element={<GerbilFormPage />} />
|
<Route path="neu" element={<GerbilFormPage />} />
|
||||||
@@ -48,7 +52,9 @@ export default function App() {
|
|||||||
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
|
<Route path=":id/bearbeiten" element={<WurfFormPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
|
{/* FEAT-2: Kontakte ersetzt das Züchter-Platzhalter-Routing (Breeder -> Contact) */}
|
||||||
<Route path="becken">
|
{/* GEHEGE-RENAME: /becken → /gehege; redirect preserves bookmarks */}
|
||||||
|
<Route path="becken/*" element={<BeckenRedirect />} />
|
||||||
|
<Route path="gehege">
|
||||||
<Route index element={<BeckenPage />} />
|
<Route index element={<BeckenPage />} />
|
||||||
<Route path="neu" element={<BeckenFormPage />} />
|
<Route path="neu" element={<BeckenFormPage />} />
|
||||||
<Route path=":id" element={<BeckenDetailPage />} />
|
<Route path=":id" element={<BeckenDetailPage />} />
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ export interface Gerbil {
|
|||||||
* as resident (backend default true).
|
* as resident (backend default true).
|
||||||
*/
|
*/
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
/** FORM-FIELDS-1: true=gehörlos, false=hörend, null=unbekannt. */
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
@@ -76,6 +78,7 @@ export interface CreateGerbil {
|
|||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
characterNote?: string | null
|
characterNote?: string | null
|
||||||
isResident?: boolean
|
isResident?: boolean
|
||||||
|
isDeaf?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
/** Payload for PUT /gerbils/{id} (all optional / partial update). */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
|
const nameless = de.pages.gerbils.nameless
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import { andFilter, condition } from '../api/gridify'
|
import { andFilter, condition } from '../api/gridify'
|
||||||
import type { Gender, Gerbil, Paged } from '../api/types'
|
import type { Gender, Gerbil, Paged } from '../api/types'
|
||||||
@@ -62,7 +64,7 @@ export default function AnimalPicker({ gender, onPick }: AnimalPickerProps) {
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{g.name}
|
{g.name || nameless}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ interface NavItem {
|
|||||||
// Primary items are always visible in the phone bottom bar; secondary items
|
// Primary items are always visible in the phone bottom bar; secondary items
|
||||||
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
|
// move into the "Mehr" sheet on phones and appear inline in the desktop sidebar.
|
||||||
const PRIMARY: NavItem[] = [
|
const PRIMARY: NavItem[] = [
|
||||||
{ to: '/', label: de.nav.home, icon: '🏠', end: true },
|
|
||||||
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
{ to: '/rennmaeuse', label: de.nav.gerbils, icon: '🐭' },
|
||||||
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
{ to: '/wuerfe', label: de.nav.litters, icon: '🍼' },
|
||||||
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
{ to: '/genetik', label: de.nav.genetics, icon: '🧬' },
|
||||||
]
|
]
|
||||||
const SECONDARY: NavItem[] = [
|
const SECONDARY: NavItem[] = [
|
||||||
{ to: '/becken', label: de.nav.enclosures, icon: '🛁' },
|
{ to: '/gehege', label: de.nav.enclosures, icon: '🏜️' },
|
||||||
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
{ to: '/kontakte', label: de.nav.contacts, icon: '📇' },
|
||||||
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
{ to: '/abgabe', label: de.nav.forSale, icon: '🏡' },
|
||||||
// INBOX-1: Anfragen-Posteingang
|
// INBOX-1: Anfragen-Posteingang
|
||||||
|
|||||||
@@ -43,16 +43,29 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ul className="farbschlag-cards">
|
<ul className="farbschlag-cards">
|
||||||
{result.byFarbschlag.map((f) => (
|
{result.byFarbschlag.map((f) => {
|
||||||
<li key={f.farbschlag} className="farbschlag-card">
|
// Representative genotype: highest-prob offspring for this farbschlag.
|
||||||
<FarbschlagImage name={f.farbschlag} />
|
const repGenotype = result.offspring.find(
|
||||||
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
(o) => o.farbschlag === f.farbschlag,
|
||||||
<span className="farbschlag-card__prob">
|
)?.genotype
|
||||||
{f.probability.percent}
|
return (
|
||||||
<small> ({f.probability.text})</small>
|
<li key={f.farbschlag} className="farbschlag-card">
|
||||||
</span>
|
<div className="farbschlag-card__img">
|
||||||
</li>
|
<FarbschlagImage name={f.farbschlag} />
|
||||||
))}
|
</div>
|
||||||
|
<div className="farbschlag-card__body">
|
||||||
|
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
||||||
|
{repGenotype && (
|
||||||
|
<code className="farbschlag-card__geno">{repGenotype}</code>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="farbschlag-card__prob">
|
||||||
|
{f.probability.percent}
|
||||||
|
<small> ({f.probability.text})</small>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -65,28 +78,30 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showGenotypes && (
|
{showGenotypes && (
|
||||||
<table className="genotype-table">
|
<div className="genotype-table-scroll">
|
||||||
<thead>
|
<table className="genotype-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>{t.genotypeLabel}</th>
|
<tr>
|
||||||
<th>{t.genotypePreview}</th>
|
<th>{t.genotypeLabel}</th>
|
||||||
<th>{t.probability}</th>
|
<th>{t.genotypePreview}</th>
|
||||||
</tr>
|
<th>{t.probability}</th>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{result.offspring.map((o) => (
|
|
||||||
<tr key={o.genotype}>
|
|
||||||
<td>
|
|
||||||
<code>{o.genotype}</code>
|
|
||||||
</td>
|
|
||||||
<td>{o.farbschlag}</td>
|
|
||||||
<td>
|
|
||||||
{o.probability.percent} <small>({o.probability.text})</small>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{result.offspring.map((o) => (
|
||||||
|
<tr key={o.genotype}>
|
||||||
|
<td>
|
||||||
|
<code>{o.genotype}</code>
|
||||||
|
</td>
|
||||||
|
<td>{o.farbschlag}</td>
|
||||||
|
<td>
|
||||||
|
{o.probability.percent} <small>({o.probability.text})</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ALL_TRAITS } from '../format/traits'
|
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits'
|
||||||
import './charakterbogen.css'
|
import './charakterbogen.css'
|
||||||
|
|
||||||
export interface CharakterbogenProps {
|
export interface CharakterbogenProps {
|
||||||
@@ -10,12 +10,6 @@ export interface CharakterbogenProps {
|
|||||||
onNoteChange: (note: string) => void
|
onNoteChange: (note: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FEAT-14: character sheet — a checkbox grid of traits + a free note.
|
|
||||||
* Controlled & reusable: rendered on the animal detail page (persisted) and in
|
|
||||||
* the Abgabe listing composer (feeds the AI sale-text). German labels from
|
|
||||||
* de.character.traits; the stored value is the trait KEY.
|
|
||||||
*/
|
|
||||||
export default function Charakterbogen({
|
export default function Charakterbogen({
|
||||||
traits,
|
traits,
|
||||||
note,
|
note,
|
||||||
@@ -29,26 +23,32 @@ export default function Charakterbogen({
|
|||||||
const next = new Set(selected)
|
const next = new Set(selected)
|
||||||
if (next.has(key)) next.delete(key)
|
if (next.has(key)) next.delete(key)
|
||||||
else next.add(key)
|
else next.add(key)
|
||||||
// Preserve the vocabulary order for stable output.
|
// Preserve vocabulary (category) order for stable output.
|
||||||
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="charakterbogen">
|
<div className="charakterbogen">
|
||||||
<ul className="trait-grid">
|
{TRAIT_CATEGORIES.map((cat) => (
|
||||||
{ALL_TRAITS.map((tr) => (
|
<section key={cat.category} className="trait-category">
|
||||||
<li key={tr.key}>
|
<h4 className="trait-category__heading">{cat.category}</h4>
|
||||||
<label className="trait-chip">
|
<ul className="trait-grid">
|
||||||
<input
|
{cat.traits.map((tr) => (
|
||||||
type="checkbox"
|
<li key={tr.key}>
|
||||||
checked={selected.has(tr.key)}
|
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}>
|
||||||
onChange={() => toggle(tr.key)}
|
<input
|
||||||
/>
|
type="checkbox"
|
||||||
<span>{tr.label}</span>
|
checked={selected.has(tr.key)}
|
||||||
</label>
|
onChange={() => toggle(tr.key)}
|
||||||
</li>
|
/>
|
||||||
))}
|
<span>{tr.label}</span>
|
||||||
</ul>
|
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.noteLabel}</span>
|
<span>{t.noteLabel}</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
52
gerbil-manager-web/src/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import './filterPanel.css'
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
/** Always visible on mobile (typically the search text input). Optional. */
|
||||||
|
searchField?: ReactNode
|
||||||
|
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
|
||||||
|
children: ReactNode
|
||||||
|
/** Number of currently active (non-default) filter values. Shown as badge. */
|
||||||
|
activeCount: number
|
||||||
|
/** Called when the reset button is clicked. */
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
|
||||||
|
* Render inside the existing `.filters` div (or replace it entirely).
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): renders all children inline, identical to today.
|
||||||
|
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
|
||||||
|
* the rest of the controls in a column drawer + a reset button.
|
||||||
|
*/
|
||||||
|
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const t = de.filterPanel
|
||||||
|
|
||||||
|
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
|
||||||
|
{searchField}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost filter-panel__toggle"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div className="filter-panel__drawer">
|
||||||
|
{children}
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
|
||||||
|
{t.resetButton}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
114
gerbil-manager-web/src/components/NameSuggestPanel.css
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
.namegen-name-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-name-row .input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__letter-input {
|
||||||
|
width: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-panel__usages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-usages-grid label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestions {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent, #2563eb);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__pick:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__meaning {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.namegen-suggestion__origin {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
132
gerbil-manager-web/src/components/NameSuggestPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { ApiError, errorCode } from '../api/client'
|
||||||
|
import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names'
|
||||||
|
import './NameSuggestPanel.css'
|
||||||
|
|
||||||
|
interface NameSuggestPanelProps {
|
||||||
|
gender: string
|
||||||
|
onPick: (name: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[]
|
||||||
|
|
||||||
|
export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) {
|
||||||
|
const t = de.namegen
|
||||||
|
const [letter, setLetter] = useState('')
|
||||||
|
const [usages, setUsages] = useState<Set<NamegenUsageCode>>(new Set(ALL_CODES))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [keyMissing, setKeyMissing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<NameSuggestion[]>([])
|
||||||
|
const [fetched, setFetched] = useState(false)
|
||||||
|
|
||||||
|
function toggleUsage(code: NamegenUsageCode) {
|
||||||
|
setUsages((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(code)) next.delete(code)
|
||||||
|
else next.add(code)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setKeyMissing(false)
|
||||||
|
setSuggestions([])
|
||||||
|
try {
|
||||||
|
const result = await suggestNames({
|
||||||
|
letter: letter.trim() || undefined,
|
||||||
|
gender: gender || undefined,
|
||||||
|
usages: [...usages] as NamegenUsageCode[],
|
||||||
|
count: 6,
|
||||||
|
})
|
||||||
|
setSuggestions(result)
|
||||||
|
setFetched(true)
|
||||||
|
} catch (err) {
|
||||||
|
if (errorCode(err) === 'NamesKeyMissing') {
|
||||||
|
setKeyMissing(true)
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : de.api.errors.unknown)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="namegen-panel" role="region" aria-label={t.panelTitle}>
|
||||||
|
<div className="namegen-panel__header">
|
||||||
|
<span>{t.panelTitle}</span>
|
||||||
|
<button type="button" className="btn" onClick={onClose} aria-label={t.close}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__filters">
|
||||||
|
<div className="namegen-panel__letter">
|
||||||
|
<label htmlFor="namegen-letter">{t.letterLabel}</label>
|
||||||
|
<input
|
||||||
|
id="namegen-letter"
|
||||||
|
className="input namegen-panel__letter-input"
|
||||||
|
value={letter}
|
||||||
|
onChange={(e) => setLetter(e.target.value)}
|
||||||
|
placeholder={t.letterPlaceholder}
|
||||||
|
maxLength={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="namegen-panel__usages">
|
||||||
|
<span>{t.usagesLabel}</span>
|
||||||
|
<div className="namegen-usages-grid">
|
||||||
|
{NAMEGEN_USAGES.map(({ code, label }) => (
|
||||||
|
<label key={code}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={usages.has(code)}
|
||||||
|
onChange={() => toggleUsage(code)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={load}
|
||||||
|
disabled={loading || usages.size === 0}
|
||||||
|
>
|
||||||
|
{loading ? t.loading : t.loadButton}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{keyMissing && <p className="muted">{t.keyMissing}</p>}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
{fetched && !loading && !keyMissing && !error && suggestions.length === 0 && (
|
||||||
|
<p className="muted">{t.empty}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<ul className="namegen-suggestions" aria-label={t.panelTitle}>
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li key={`${s.name}-${i}`} className="namegen-suggestion">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="namegen-suggestion__pick"
|
||||||
|
onClick={() => onPick(s.name)}
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</button>
|
||||||
|
<span className="namegen-suggestion__meaning">{s.meaning}</span>
|
||||||
|
<span className="namegen-suggestion__origin">{s.origin}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
/* FEAT-14 Charakterbogen — trait checkbox grid (mobile-first). */
|
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */
|
||||||
|
|
||||||
|
.trait-category {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-category__heading {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.charakterbogen .trait-grid {
|
.charakterbogen .trait-grid {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.5rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -39,3 +52,16 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trait-chip--warn {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: #fff7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trait-warn-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d97706;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
53
gerbil-manager-web/src/components/filterPanel.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
|
||||||
|
*
|
||||||
|
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
|
||||||
|
* children participate directly in the parent .filters flex row.
|
||||||
|
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
|
||||||
|
* width drawer (flex column) with the remaining filters + reset button.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Mobile default ── */
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel--open .filter-panel__drawer {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ── */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.filter-panel__toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel__drawer {
|
||||||
|
/* Let children participate directly in the parent flex row. */
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset button sits in the flex row on desktop when active filters exist. */
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile reset button ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.filter-panel__reset-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
91
gerbil-manager-web/src/format/__tests__/traits.test.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ALL_TRAITS, TRAIT_CATEGORIES, traitLabel, traitLabels, isWarnTrait } from '../traits'
|
||||||
|
|
||||||
|
describe('trait catalog', () => {
|
||||||
|
it('has 4 categories', () => {
|
||||||
|
expect(TRAIT_CATEGORIES).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('category names match spec', () => {
|
||||||
|
const names = TRAIT_CATEGORIES.map((c) => c.category)
|
||||||
|
expect(names).toEqual([
|
||||||
|
'Sozialverhalten',
|
||||||
|
'Eignung & Umgang',
|
||||||
|
'Hobbys & Eigenarten',
|
||||||
|
'Wesen & Temperament',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ALL_TRAITS flattens all categories', () => {
|
||||||
|
const total = TRAIT_CATEGORIES.reduce((sum, c) => sum + c.traits.length, 0)
|
||||||
|
expect(ALL_TRAITS).toHaveLength(total)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all keys are unique', () => {
|
||||||
|
const keys = ALL_TRAITS.map((t) => t.key)
|
||||||
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('existing 15 keys are still present (stored on live animals)', () => {
|
||||||
|
const legacy = [
|
||||||
|
'zutraulich', 'handzahm', 'neugierig', 'aufgeschlossen', 'ruhig', 'lebhaft',
|
||||||
|
'verschmust', 'eigenstaendig', 'anfaengergeeignet', 'futterfreudig',
|
||||||
|
'buddelt', 'klettert', 'laufrad', 'vertraeglich', 'schreckhaft',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of legacy) {
|
||||||
|
expect(allKeys.has(key), `missing legacy key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('new keys are present', () => {
|
||||||
|
const newKeys = [
|
||||||
|
'dominant', 'rangniedrig', 'sozialkompetent', 'schwer-vergesellschaftbar',
|
||||||
|
'erfahrene-halter', 'beobachtungstier', 'familiengeeignet',
|
||||||
|
'schredder', 'nestbauer', 'territorial',
|
||||||
|
]
|
||||||
|
const allKeys = new Set(ALL_TRAITS.map((t) => t.key))
|
||||||
|
for (const key of newKeys) {
|
||||||
|
expect(allKeys.has(key), `missing new key: ${key}`).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warn traits are schwer-vergesellschaftbar and territorial', () => {
|
||||||
|
expect(isWarnTrait('schwer-vergesellschaftbar')).toBe(true)
|
||||||
|
expect(isWarnTrait('territorial')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-warn traits return false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('zutraulich')).toBe(false)
|
||||||
|
expect(isWarnTrait('dominant')).toBe(false)
|
||||||
|
expect(isWarnTrait('beobachtungstier')).toBe(false)
|
||||||
|
expect(isWarnTrait('schreckhaft')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown key returns false from isWarnTrait', () => {
|
||||||
|
expect(isWarnTrait('not-a-real-key')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns German label for known key', () => {
|
||||||
|
expect(traitLabel('zutraulich')).toBe('zutraulich')
|
||||||
|
expect(traitLabel('dominant')).toBe('dominant (Leittier)')
|
||||||
|
expect(traitLabel('schwer-vergesellschaftbar')).toBe('schwer vergesellschaftbar')
|
||||||
|
expect(traitLabel('territorial')).toBe('territorial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabel returns the key itself for unknown key', () => {
|
||||||
|
expect(traitLabel('not-a-real-key')).toBe('not-a-real-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels maps a list of keys to labels', () => {
|
||||||
|
expect(traitLabels(['zutraulich', 'dominant'])).toEqual([
|
||||||
|
'zutraulich',
|
||||||
|
'dominant (Leittier)',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('traitLabels handles null and undefined gracefully', () => {
|
||||||
|
expect(traitLabels(null)).toEqual([])
|
||||||
|
expect(traitLabels(undefined)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
/** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
|
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
|
||||||
export const ALL_TRAITS = de.character.traits
|
export interface TraitEntry {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
warn?: true
|
||||||
|
}
|
||||||
|
|
||||||
const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
|
export interface TraitCategory {
|
||||||
|
category: string
|
||||||
|
traits: readonly TraitEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRAIT_CATEGORIES: ReadonlyArray<TraitCategory> =
|
||||||
|
de.character.traitCategories as unknown as ReadonlyArray<TraitCategory>
|
||||||
|
|
||||||
|
export const ALL_TRAITS: readonly TraitEntry[] = TRAIT_CATEGORIES.flatMap((c) => c.traits)
|
||||||
|
|
||||||
|
const LABEL_BY_KEY = new Map<string, string>(ALL_TRAITS.map((t) => [t.key, t.label]))
|
||||||
|
|
||||||
export function traitLabel(key: string): string {
|
export function traitLabel(key: string): string {
|
||||||
return LABEL_BY_KEY.get(key) ?? key
|
return LABEL_BY_KEY.get(key) ?? key
|
||||||
@@ -13,3 +27,7 @@ export function traitLabel(key: string): string {
|
|||||||
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
export function traitLabels(keys: readonly string[] | null | undefined): string[] {
|
||||||
return (keys ?? []).map(traitLabel)
|
return (keys ?? []).map(traitLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWarnTrait(key: string): boolean {
|
||||||
|
return ALL_TRAITS.some((t) => t.key === key && t.warn === true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* AR-5 Drift-Guard: colorVarietySeed.generated.json muss mit dem Live-Output
|
||||||
|
* aus catalog.ts übereinstimmen.
|
||||||
|
*
|
||||||
|
* Schlägt dieser Test fehl, wurde catalog.ts verändert ohne danach
|
||||||
|
* `npm run gen:catalog` auszuführen. Fix: `npm run gen:catalog` laufen lassen
|
||||||
|
* und die geänderten JSON-Dateien committen.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { CATALOG } from '../catalog'
|
||||||
|
|
||||||
|
const __dir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
describe('AR-5 Catalog drift-guard', () => {
|
||||||
|
it('colorVarietySeed.generated.json stimmt mit catalog.ts überein (sonst: npm run gen:catalog)', () => {
|
||||||
|
const jsonPath = join(__dir, '..', 'colorVarietySeed.generated.json')
|
||||||
|
const committed = JSON.parse(readFileSync(jsonPath, 'utf-8'))
|
||||||
|
// CATALOG is readonly — deep equality against the plain parsed array is sufficient.
|
||||||
|
expect(committed).toEqual(Array.from(CATALOG))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -179,16 +179,21 @@ describe('Farbschlag catalog', () => {
|
|||||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => {
|
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
|
||||||
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named
|
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
|
||||||
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61.
|
expect(CATALOG_SIZE).toBe(70)
|
||||||
expect(CATALOG_SIZE).toBe(61)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||||
// The first 18 are the frozen ColorVariety keys — their representative
|
// The first 18 are the frozen ColorVariety keys — their representative
|
||||||
// genotype MUST resolve back to their own name, never a later variety.
|
// genotype MUST resolve back to their own name, never a later variety.
|
||||||
|
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
|
||||||
|
// because the REW engine check (both C-alleles reduced + pp) fires first.
|
||||||
|
// PEW stays in the catalog as a user-pickable import name; its computed
|
||||||
|
// farbschlag is intentionally 'REW' per Julian's extended rule.
|
||||||
|
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
|
||||||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||||||
|
if (REW_SHADOWED.has(entry.name)) continue
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -221,8 +226,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', () => {
|
||||||
@@ -371,9 +377,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
|
||||||
|
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
|
||||||
|
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
|
||||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||||
'Fuchsschimmel',
|
'Kohlfuchsschimmel',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||||
@@ -399,9 +407,11 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
|||||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
|
it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
|
||||||
|
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
|
||||||
|
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
|
||||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||||
'Fuchsschimmel',
|
'Kohlfuchsschimmel',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -414,8 +424,13 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
|||||||
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('A- + cchm/ch -> CP-<base colour>', () => {
|
it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
|
||||||
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||||
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
|
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
@@ -465,16 +480,206 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
|||||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||||
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||||||
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||||||
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
|
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
|
||||||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
|
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
|
||||||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => {
|
it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
|
||||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||||
|
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||||
|
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
|
||||||
|
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||||
|
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
|
||||||
|
|
||||||
|
it('all new -Hell het entries exist in catalog', () => {
|
||||||
|
for (const n of [
|
||||||
|
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
|
||||||
|
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
|
||||||
|
]) {
|
||||||
|
expect(has(n)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
|
||||||
|
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
|
||||||
|
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||||
|
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||||
|
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
|
||||||
|
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||||||
|
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
|
||||||
|
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
|
||||||
|
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||||
|
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||||
|
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
|
||||||
|
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
|
||||||
|
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
|
||||||
|
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||||
|
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||||
|
|
||||||
|
it('dd entries use Dilute prefix — no more X-dd names', () => {
|
||||||
|
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||||
|
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||||
|
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||||
|
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||||
|
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||||
|
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
||||||
|
// hom cchm/cchm + pp
|
||||||
|
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
|
||||||
|
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
||||||
|
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
||||||
|
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
||||||
|
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
||||||
|
// het cchm/ch + pp (Julian: also REW)
|
||||||
|
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
|
// ch/ch + pp (Julian: also REW — subsumes PEW)
|
||||||
|
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
|
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
||||||
|
// Counterproof: full C present → NOT REW (residual pigment)
|
||||||
|
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
|
||||||
|
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Farbarten (categories) never appear as computed results', () => {
|
||||||
|
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||||
|
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||||
|
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||||
|
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||||
|
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||||
|
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||||
|
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||||
|
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||||
|
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
|
||||||
|
for (const cat of CATS) {
|
||||||
|
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
|
||||||
|
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
|
||||||
|
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
|
||||||
|
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
|
||||||
|
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
|
||||||
|
// ── Display symbols ────────────────────────────────────────────────────
|
||||||
|
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||||
|
// Fuchsschimmel: E=[ef,ef] hom
|
||||||
|
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||||
|
'AA CC DD e[f]e[f] GG PP spsp 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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
||||||
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
||||||
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
||||||
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
||||||
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
{ name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
||||||
|
|
||||||
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
|
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
|
||||||
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
|
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
|
||||||
@@ -78,16 +78,24 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||||
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
|
||||||
|
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||||
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
||||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||||
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||||
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||||
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||||
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
{ name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||||
|
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
|
||||||
|
// agouti+dilute+fox combinations not otherwise covered in the catalog.
|
||||||
|
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
||||||
|
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||||
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
||||||
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
||||||
|
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
|
||||||
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
||||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||||
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
||||||
@@ -104,30 +112,32 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
|||||||
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
||||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||||
|
|
||||||
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ──
|
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||||
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the
|
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||||
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant);
|
// GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
|
||||||
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants
|
// A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
|
||||||
// collapse (other loci irrelevant for the CP prefix). These names == the strings
|
// CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
|
||||||
// in her live data (god: extract animals.json) so the re-import name-matches and
|
// CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
|
||||||
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell,
|
// fox CP has no dedicated base entry); kept for import name-match + hand-pick.
|
||||||
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
|
|
||||||
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
|
|
||||||
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
|
|
||||||
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
|
||||||
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
|
||||||
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
|
||||||
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
|
||||||
|
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
|
||||||
|
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
|
||||||
|
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||||
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||||
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
|
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
|
||||||
@@ -145,10 +155,12 @@ export interface FarbschlagMatch {
|
|||||||
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
||||||
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
||||||
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
||||||
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
|
* a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
|
||||||
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
|
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
|
||||||
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
|
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
|
||||||
* raw pair, which runs before this.)
|
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
|
||||||
|
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
|
||||||
|
* variety specifically.
|
||||||
*/
|
*/
|
||||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||||
@@ -160,7 +172,9 @@ function locusToken(g: Genotype, locus: LocusKey): string {
|
|||||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||||
if (locus === 'E') {
|
if (locus === 'E') {
|
||||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
|
||||||
|
// enables catalog entries like Kohlfuchsschimmel to match het animals).
|
||||||
|
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
|
||||||
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||||
}
|
}
|
||||||
return dominantAllele(locus, x, y)
|
return dominantAllele(locus, x, y)
|
||||||
@@ -173,10 +187,12 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
|
* E-locus family: used to scope the catalog search to E-aware entries.
|
||||||
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
|
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
|
||||||
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
|
* implies a non-dominant extension pair, or null for full-extension/unknown.
|
||||||
* Returns null when E is dominant (full colour) or fully unknown.
|
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
|
||||||
|
* They are ONLY used here as catalog-search filters; they must NEVER appear as
|
||||||
|
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||||
*/
|
*/
|
||||||
function eFamily(g: Genotype): string | null {
|
function eFamily(g: Genotype): string | null {
|
||||||
const [x, y] = g.E
|
const [x, y] = g.E
|
||||||
@@ -203,16 +219,21 @@ function baseColourFor(g: Genotype): string | null {
|
|||||||
const base = family
|
const base = family
|
||||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||||
return base?.name ?? family
|
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||||
|
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
||||||
|
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
|
||||||
|
return base?.name ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
* GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
|
||||||
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
* Returns the colourpoint name, or null when it doesn't apply (full C present,
|
||||||
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
* or chch — which the base matcher names Hermelin/Himalaya, preserving both).
|
||||||
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam
|
||||||
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
|
||||||
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full)
|
* A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
|
||||||
|
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
|
||||||
|
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
|
||||||
*/
|
*/
|
||||||
function colourpointName(g: Genotype): string | null {
|
function colourpointName(g: Genotype): string | null {
|
||||||
const c = resolvedPair(g, 'C')
|
const c = resolvedPair(g, 'C')
|
||||||
@@ -227,9 +248,15 @@ function colourpointName(g: Genotype): string | null {
|
|||||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||||
return bothCchm ? 'Marder' : 'Siam'
|
return bothCchm ? 'Marder' : 'Siam'
|
||||||
}
|
}
|
||||||
// A- colourpoint -> CP-<base colour>, base as if C were full.
|
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||||
return base ? `CP-${base}` : null
|
if (!base) return null
|
||||||
|
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
||||||
|
const DILUTE = 'Dilute '
|
||||||
|
if (base.startsWith(DILUTE)) {
|
||||||
|
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
||||||
|
}
|
||||||
|
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||||
@@ -237,8 +264,30 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
|||||||
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||||
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||||
|
|
||||||
|
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
|
||||||
|
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
||||||
|
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
||||||
|
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
||||||
|
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
|
||||||
|
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
||||||
|
const [c0, c1] = resolvedPair(g, 'C')
|
||||||
|
const [p0, p1] = resolvedPair(g, 'P')
|
||||||
|
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
|
||||||
|
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
|
||||||
|
const name = ['REW', ...modifiers].join(' ')
|
||||||
|
return { name, base: null, unknown: false }
|
||||||
|
}
|
||||||
|
|
||||||
const baseName = colourpointName(g) ?? baseColourFor(g)
|
const baseName = colourpointName(g) ?? baseColourFor(g)
|
||||||
if (!baseName) {
|
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
|
||||||
|
// a computed Farbschlag output. If baseName is a category label, treat as
|
||||||
|
// Unbekannt instead of leaking an invalid name into the UI.
|
||||||
|
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
|
||||||
|
'Standard', 'Colourpoint', 'Dilute',
|
||||||
|
'Fuchs', 'Fuchsschimmel', 'Schimmel',
|
||||||
|
'Colourpoint Dilute',
|
||||||
|
])
|
||||||
|
if (!baseName || CATEGORY_NAMES.has(baseName)) {
|
||||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||||
}
|
}
|
||||||
const name = [baseName, ...modifiers].join(' ')
|
const name = [baseName, ...modifiers].join(' ')
|
||||||
|
|||||||
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
426
gerbil-manager-web/src/genetics/colorVarietySeed.backend.json
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "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": "Dilute Gold",
|
||||||
|
"english": "dd Argente Golden",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 15,
|
||||||
|
"image": "gold-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Platin",
|
||||||
|
"english": "dd Lilac",
|
||||||
|
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 16,
|
||||||
|
"image": "platin-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Altweiss (REW)",
|
||||||
|
"canonicalGenotype": "aa CC DD EE gg pp spsp rere",
|
||||||
|
"sortOrder": 17,
|
||||||
|
"image": "altweiss-rew.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Apricot (Blassfuchs)",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg pp spsp rere",
|
||||||
|
"sortOrder": 18,
|
||||||
|
"image": "apricot-blassfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchs",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 19,
|
||||||
|
"image": "blaufuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "C-Separator",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg pp spsp rere",
|
||||||
|
"sortOrder": 20,
|
||||||
|
"image": "c-separator.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Elfenbein",
|
||||||
|
"canonicalGenotype": "AA CC DD EE gg pp spsp rere",
|
||||||
|
"sortOrder": 21,
|
||||||
|
"image": "elfenbein.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 22,
|
||||||
|
"image": "kohlfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 23,
|
||||||
|
"image": "polarfuchs.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Saphir",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 24,
|
||||||
|
"image": "saphir.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 25,
|
||||||
|
"image": "schimmel-orangeschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Topas",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 26,
|
||||||
|
"image": "topas.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Platin-Hell",
|
||||||
|
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 27,
|
||||||
|
"image": "platin-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Agouti",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
|
"sortOrder": 28,
|
||||||
|
"image": "agouti-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Silberagouti",
|
||||||
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
|
"sortOrder": 29,
|
||||||
|
"image": "silberagouti-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Kohlfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 30,
|
||||||
|
"image": "kohlfuchs-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Anthrazit",
|
||||||
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
|
"sortOrder": 31,
|
||||||
|
"image": "anthrazit-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Goldfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Rotfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 35
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silberschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 36,
|
||||||
|
"image": "silberschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 37,
|
||||||
|
"image": "polarfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 38,
|
||||||
|
"image": "algierfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 39,
|
||||||
|
"image": "kohlfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||||
|
"sortOrder": 40,
|
||||||
|
"image": "blaufuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 41,
|
||||||
|
"image": "kohlfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 42,
|
||||||
|
"image": "goldfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Goldfuchsschimmel",
|
||||||
|
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 43,
|
||||||
|
"image": "goldfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gold-Hell",
|
||||||
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
|
"sortOrder": 44,
|
||||||
|
"image": "gold-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Blaufuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 45,
|
||||||
|
"image": "blaufuchs-hell.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchsschimmel",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||||
|
"sortOrder": 46,
|
||||||
|
"image": "rotfuchsschimmel.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Polarfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 47,
|
||||||
|
"image": "polarfuchs-hell.jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchsschimmel, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 48,
|
||||||
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rotfuchs, hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
|
"sortOrder": 49,
|
||||||
|
"image": "rotfuchs-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kohlfuchs-Hell",
|
||||||
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 50,
|
||||||
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Algierfuchs, hell",
|
||||||
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 51,
|
||||||
|
"image": "algierfuchs-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Topas",
|
||||||
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
|
"sortOrder": 52,
|
||||||
|
"image": "topas-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Blaufuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
|
"sortOrder": 53,
|
||||||
|
"image": "blaufuchs-dd.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marder",
|
||||||
|
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 54,
|
||||||
|
"image": "marder.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Siam",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 55,
|
||||||
|
"image": "siam-marder-hell.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Zobel-Hell",
|
||||||
|
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 56,
|
||||||
|
"image": "zobel-hell.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 57,
|
||||||
|
"image": "agouti-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Agouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||||
|
"sortOrder": 58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 59,
|
||||||
|
"image": "silberagouti-cp.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 61,
|
||||||
|
"image": "algierfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 63,
|
||||||
|
"image": "polarfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 66
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Blaufuchs",
|
||||||
|
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel",
|
||||||
|
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||||
|
"sortOrder": 69
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -105,14 +105,14 @@
|
|||||||
"image": "rotfuchs.JPG"
|
"image": "rotfuchs.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dd Gold",
|
"name": "Dilute Gold",
|
||||||
"english": "dd Argente Golden",
|
"english": "dd Argente Golden",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 15,
|
"sortOrder": 15,
|
||||||
"image": "gold-dd.jpg"
|
"image": "gold-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "dd Platin",
|
"name": "Dilute Platin",
|
||||||
"english": "dd Lilac",
|
"english": "dd Lilac",
|
||||||
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "aa CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 16,
|
"sortOrder": 16,
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -185,197 +185,242 @@
|
|||||||
"image": "platin-hell.jpg"
|
"image": "platin-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Agouti dd",
|
"name": "Dilute Agouti",
|
||||||
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG PP spsp rere",
|
||||||
"sortOrder": 28,
|
"sortOrder": 28,
|
||||||
"image": "agouti-dd.jpg"
|
"image": "agouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberagouti dd",
|
"name": "Dilute Silberagouti",
|
||||||
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "AA CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 29,
|
"sortOrder": 29,
|
||||||
"image": "silberagouti-dd.jpg"
|
"image": "silberagouti-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs dd",
|
"name": "Dilute Kohlfuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC dd ee GG PP spsp rere",
|
||||||
"sortOrder": 30,
|
"sortOrder": 30,
|
||||||
"image": "kohlfuchs-dd.jpg"
|
"image": "kohlfuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Anthrazit dd",
|
"name": "Dilute Anthrazit",
|
||||||
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
"canonicalGenotype": "aa CC dd EE gg PP spsp rere",
|
||||||
"sortOrder": 31,
|
"sortOrder": 31,
|
||||||
"image": "anthrazit-dd.jpg"
|
"image": "anthrazit-dd.jpg"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Goldfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 33
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Rotfuchs",
|
||||||
|
"canonicalGenotype": "aa CC dd ee GG pp spsp rere",
|
||||||
|
"sortOrder": 34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dilute Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA CC dd ee gg PP spsp rere",
|
||||||
|
"sortOrder": 35
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Silberschimmel",
|
"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": 36,
|
||||||
"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": 37,
|
||||||
"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": 38,
|
||||||
"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": 39,
|
||||||
"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": 40,
|
||||||
"image": "blaufuchsschimmel.jpg"
|
"image": "blaufuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs, hell",
|
"name": "Kohlfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 37,
|
"sortOrder": 41,
|
||||||
"image": "kohlfuchs-hell.jpg"
|
"image": "kohlfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Goldfuchs, hell",
|
"name": "Goldfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 38,
|
"sortOrder": 42,
|
||||||
"image": "goldfuchs-hell.jpg"
|
"image": "goldfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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": 43,
|
||||||
"image": "goldfuchsschimmel.jpg"
|
"image": "goldfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gold-Hell",
|
"name": "Gold-Hell",
|
||||||
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC DD EE GG pp spsp rere",
|
||||||
"sortOrder": 40,
|
"sortOrder": 44,
|
||||||
"image": "gold-hell.jpg"
|
"image": "gold-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs, hell",
|
"name": "Blaufuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 41,
|
"sortOrder": 45,
|
||||||
"image": "blaufuchs-hell.jpeg"
|
"image": "blaufuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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": 46,
|
||||||
"image": "rotfuchsschimmel.jpg"
|
"image": "rotfuchsschimmel.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Polarfuchs, hell",
|
"name": "Polarfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee gg PP spsp rere",
|
||||||
"sortOrder": 43,
|
"sortOrder": 47,
|
||||||
"image": "polarfuchs-hell.jpeg"
|
"image": "polarfuchs-hell.jpeg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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": 48,
|
||||||
"image": "kohlfuchsschimmel-hell.jpg"
|
"image": "kohlfuchsschimmel-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Rotfuchs, hell",
|
"name": "Rotfuchs, hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG pp spsp rere",
|
||||||
"sortOrder": 45,
|
"sortOrder": 49,
|
||||||
"image": "rotfuchs-hell.jpg"
|
"image": "rotfuchs-hell.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Kohlfuchs-Hell",
|
"name": "Kohlfuchs-Hell",
|
||||||
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "aa CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 46,
|
"sortOrder": 50,
|
||||||
"image": "kohlfuchs-hell-2.jpg"
|
"image": "kohlfuchs-hell-2.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Algierfuchs, hell",
|
"name": "Algierfuchs, hell",
|
||||||
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
"canonicalGenotype": "AA CC DD ee GG PP spsp rere",
|
||||||
"sortOrder": 47,
|
"sortOrder": 51,
|
||||||
"image": "algierfuchs-hell.JPG"
|
"image": "algierfuchs-hell.JPG"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Topas dd",
|
"name": "Dilute Topas",
|
||||||
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
"canonicalGenotype": "AA CC dd EE GG pp spsp rere",
|
||||||
"sortOrder": 48,
|
"sortOrder": 52,
|
||||||
"image": "topas-dd.jpg"
|
"image": "topas-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Blaufuchs dd",
|
"name": "Dilute Blaufuchs",
|
||||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||||
"sortOrder": 49,
|
"sortOrder": 53,
|
||||||
"image": "blaufuchs-dd.jpg"
|
"image": "blaufuchs-dd.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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": 54,
|
||||||
"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": 55,
|
||||||
"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": 56,
|
||||||
"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": 57,
|
||||||
"image": "agouti-cp.jpg"
|
"image": "agouti-cp.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "CP-Silberagouti",
|
"name": "CP-Agouti-Hell",
|
||||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||||
"sortOrder": 54,
|
|
||||||
"image": "silberagouti-cp.JPG"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Algierfuchs",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
|
||||||
"sortOrder": 55,
|
|
||||||
"image": "algierfuchs-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Polarfuchs",
|
|
||||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
|
||||||
"sortOrder": 56,
|
|
||||||
"image": "polarfuchs-cp.jpg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Fuchs",
|
|
||||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
|
||||||
"sortOrder": 57
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CP-Fuchs-Hell",
|
|
||||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
|
||||||
"sortOrder": 58
|
"sortOrder": 58
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 59,
|
||||||
|
"image": "silberagouti-cp.JPG"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Silberagouti-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 61,
|
||||||
|
"image": "algierfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Algierfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||||
|
"sortOrder": 62
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 63,
|
||||||
|
"image": "polarfuchs-cp.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Polarfuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||||
|
"sortOrder": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 65
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Fuchs-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||||
|
"sortOrder": 66
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"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": 59
|
"sortOrder": 67
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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": 60
|
"sortOrder": 68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CP-Orangeschimmel-Hell",
|
||||||
|
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||||
|
"sortOrder": 69
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ a {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
max-width: 60rem;
|
max-width: 60rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
/* UX-MOBILE-2: prevent any wide content (e.g. genotype table) from making
|
||||||
|
the page body scroll horizontally. The .genotype-table-scroll wrapper
|
||||||
|
provides the per-table horizontal scroll. */
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab-Leiste unten */
|
/* Tab-Leiste unten */
|
||||||
@@ -535,32 +539,67 @@ textarea {
|
|||||||
margin: 0.5rem 0 1rem;
|
margin: 0.5rem 0 1rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
/* GEN-4: wider min-width prevents long names (CP-Silberagouti-Hell) from overflowing */
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GEN-4 layout fix: image left, body centre (name + genotype stacked), prob right.
|
||||||
|
align-items: stretch so all three columns fill the card height consistently. */
|
||||||
.farbschlag-card {
|
.farbschlag-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
overflow: hidden; /* contain long genotype strings */
|
||||||
|
}
|
||||||
|
|
||||||
|
.farbschlag-card__img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body takes the middle space and allows wrapping for long names */
|
||||||
|
.farbschlag-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* allow flex child to shrink below content size */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.farbschlag-card__name {
|
.farbschlag-card__name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Genotype code shown below the name — muted, small, wrappable */
|
||||||
|
.farbschlag-card__geno {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-all;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.farbschlag-card__prob {
|
.farbschlag-card__prob {
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
|
||||||
|
instead of overflowing the page. */
|
||||||
|
.genotype-table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genotype-table {
|
.genotype-table {
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -571,6 +610,7 @@ textarea {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.genotype-table code {
|
.genotype-table code {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '../api/requests'
|
} from '../api/requests'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDateTime } from '../format/labels'
|
import { formatDateTime } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './anfragen.css'
|
import './anfragen.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -75,7 +76,10 @@ export default function AnfragenPage() {
|
|||||||
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
{sync.error && <div className="alert alert--error">{sync.error}</div>}
|
||||||
|
|
||||||
{/* Status-Filter */}
|
{/* Status-Filter */}
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={status !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setStatus(''); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.detail.statusLabel}</span>
|
<span>{t.detail.statusLabel}</span>
|
||||||
<select
|
<select
|
||||||
@@ -93,7 +97,7 @@ export default function AnfragenPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
{requests.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{requests.error && (
|
{requests.error && (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function BeckenDetailPage() {
|
|||||||
setDeleteError(null)
|
setDeleteError(null)
|
||||||
const result = await removal.run()
|
const result = await removal.run()
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
navigate('/becken')
|
navigate('/gehege')
|
||||||
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
} else if (result.cause instanceof ApiError && result.cause.status === 409) {
|
||||||
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
// Backend meldet Konflikt, wenn noch Tiere im Becken wohnen.
|
||||||
setDeleteError(t.delete.conflict)
|
setDeleteError(t.delete.conflict)
|
||||||
@@ -58,7 +58,7 @@ export default function BeckenDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<section className="page">
|
<section className="page">
|
||||||
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
<p className="muted">{enclosure.error ?? t.detail.notFound}</p>
|
||||||
<Link to="/becken" className="btn">
|
<Link to="/gehege" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
@@ -81,7 +81,7 @@ export default function BeckenDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<Link to={`/becken/${e.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/gehege/${e.id}/bearbeiten`} className="btn btn--primary">
|
||||||
{t.detail.edit}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +92,7 @@ export default function BeckenDetailPage() {
|
|||||||
>
|
>
|
||||||
{t.delete.action}
|
{t.delete.action}
|
||||||
</button>
|
</button>
|
||||||
<Link to="/becken" className="btn">
|
<Link to="/gehege" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function BeckenFormPage() {
|
|||||||
}
|
}
|
||||||
setErrors({})
|
setErrors({})
|
||||||
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
const result = await mutation.run({ name: form.name.trim(), notes: nn(form.notes) })
|
||||||
if (result.ok) navigate(`/becken/${result.value.id}`)
|
if (result.ok) navigate(`/gehege/${result.value.id}`)
|
||||||
// On failure mutation.error drives the inline alert; run() never throws.
|
// On failure mutation.error drives the inline alert; run() never throws.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function BeckenFormPage() {
|
|||||||
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
||||||
{mutation.pending ? t.form.saving : t.form.save}
|
{mutation.pending ? t.form.saving : t.form.save}
|
||||||
</button>
|
</button>
|
||||||
<Link to={isEdit && id ? `/becken/${id}` : '/becken'} className="btn">
|
<Link to={isEdit && id ? `/gehege/${id}` : '/gehege'} className="btn">
|
||||||
{t.form.cancel}
|
{t.form.cancel}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function BeckenPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link to="/becken/neu" className="btn btn--primary">
|
<Link to="/gehege/neu" className="btn btn--primary">
|
||||||
+ {t.newButton}
|
+ {t.newButton}
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
@@ -72,7 +72,7 @@ export default function BeckenPage() {
|
|||||||
<ul className="card-list">
|
<ul className="card-list">
|
||||||
{items.map((e) => (
|
{items.map((e) => (
|
||||||
<li key={e.id}>
|
<li key={e.id}>
|
||||||
<Link to={`/becken/${e.id}`} className="gerbil-card">
|
<Link to={`/gehege/${e.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">{e.name}</span>
|
<span className="gerbil-card__name">{e.name}</span>
|
||||||
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
|
<span className="gerbil-card__meta">{e.notes ?? ''}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState, type ReactNode } from 'react'
|
|||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { getGerbil, updateGerbil } from '../api/gerbils'
|
import { getGerbil, updateGerbil } from '../api/gerbils'
|
||||||
|
import { listLitters as listLittersPaged } from '../api/litters'
|
||||||
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
@@ -55,6 +56,10 @@ export default function GerbilDetailPage() {
|
|||||||
const enclosures = useApi(() => listEnclosures(), [])
|
const enclosures = useApi(() => listEnclosures(), [])
|
||||||
const contacts = useApi(() => listContacts(), [])
|
const contacts = useApi(() => listContacts(), [])
|
||||||
const litters = useApi(() => listLitters(), [])
|
const litters = useApi(() => listLitters(), [])
|
||||||
|
const parentLitters = useApi(
|
||||||
|
() => listLittersPaged({ filter: `fatherId=${id}|motherId=${id}`, orderBy: 'date desc', pageSize: 50 }),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
const colorName = useMemo(
|
const colorName = useMemo(
|
||||||
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
() => new Map((colorVarieties.data ?? []).map((c) => [c.id, c.name])),
|
||||||
@@ -102,7 +107,7 @@ export default function GerbilDetailPage() {
|
|||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<GerbilProfilePhoto gerbilId={g.id} />
|
<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>
|
<span className={`badge badge--${g.status.toLowerCase()}`}>{statusLabel(g.status)}</span>
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
<span className="badge badge--external" title={de.pages.gerbils.externalTitle}>
|
||||||
@@ -153,7 +158,7 @@ export default function GerbilDetailPage() {
|
|||||||
{g.status === 'Deceased' && (
|
{g.status === 'Deceased' && (
|
||||||
<>
|
<>
|
||||||
<Row label={t.fields.dateOfDeath} value={formatDate(g.dateOfDeath)} />
|
<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' && (
|
{g.status === 'GivenAway' && (
|
||||||
@@ -173,10 +178,24 @@ export default function GerbilDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
<Row label={t.fields.enclosure} value={lookup(enclosureName, g.enclosureId)} />
|
||||||
<Row label={t.fields.litter} value={lookup(litterName, g.litterId)} />
|
<Row
|
||||||
<Row label={t.fields.origin} value={lookup(contactName, g.originContactId)} />
|
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.receiver} value={lookup(contactName, g.receiverContactId)} />
|
||||||
<Row label={t.fields.notes} value={g.notes} />
|
<Row label={t.fields.notes} value={g.notes || null} />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h3>{t.detail.genetics}</h3>
|
<h3>{t.detail.genetics}</h3>
|
||||||
@@ -223,6 +242,32 @@ export default function GerbilDetailPage() {
|
|||||||
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
{saveCharacter.error && <span className="error-text">{saveCharacter.error}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3>{t.detail.parentLittersTitle}</h3>
|
||||||
|
{parentLitters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
|
{!parentLitters.loading && (parentLitters.data?.items.length ?? 0) === 0 && (
|
||||||
|
<p className="muted">{t.detail.parentLittersEmpty}</p>
|
||||||
|
)}
|
||||||
|
{(parentLitters.data?.items.length ?? 0) > 0 && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{(parentLitters.data?.items ?? []).map((litter) => (
|
||||||
|
<li key={litter.id}>
|
||||||
|
<Link to={`/wuerfe/${litter.id}`} className="gerbil-card">
|
||||||
|
<span className="gerbil-card__name">{litter.name}</span>
|
||||||
|
<span className="badge badge--active">
|
||||||
|
{litter.fatherId === id ? t.detail.parentLittersRoleVater : t.detail.parentLittersRoleMutter}
|
||||||
|
</span>
|
||||||
|
<span className="gerbil-card__meta">{formatDate(litter.date)}</span>
|
||||||
|
{litter.totalBorn !== null && (
|
||||||
|
<span className="gerbil-card__meta">
|
||||||
|
{litter.totalBorn} {de.pages.litters.countLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,10 +23,12 @@ interface FormState {
|
|||||||
enclosureId: string
|
enclosureId: string
|
||||||
litterId: string
|
litterId: string
|
||||||
originContactId: string
|
originContactId: string
|
||||||
|
originBreeder: string
|
||||||
receiverContactId: string
|
receiverContactId: string
|
||||||
genotype: string
|
genotype: string
|
||||||
notes: string
|
notes: string
|
||||||
isResident: boolean
|
isResident: boolean
|
||||||
|
isDeaf: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
const EMPTY: FormState = {
|
||||||
@@ -39,10 +43,12 @@ const EMPTY: FormState = {
|
|||||||
enclosureId: '',
|
enclosureId: '',
|
||||||
litterId: '',
|
litterId: '',
|
||||||
originContactId: '',
|
originContactId: '',
|
||||||
|
originBreeder: '',
|
||||||
receiverContactId: '',
|
receiverContactId: '',
|
||||||
genotype: '',
|
genotype: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
isResident: true,
|
isResident: true,
|
||||||
|
isDeaf: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formFromGerbil(g: {
|
function formFromGerbil(g: {
|
||||||
@@ -57,10 +63,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: string | null
|
enclosureId: string | null
|
||||||
litterId: string | null
|
litterId: string | null
|
||||||
originContactId: string | null
|
originContactId: string | null
|
||||||
|
originBreeder?: string | null
|
||||||
receiverContactId: string | null
|
receiverContactId: string | null
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
isResident?: boolean | null
|
isResident?: boolean | null
|
||||||
|
isDeaf?: boolean | null
|
||||||
}): FormState {
|
}): FormState {
|
||||||
return {
|
return {
|
||||||
name: g.name,
|
name: g.name,
|
||||||
@@ -74,10 +82,12 @@ function formFromGerbil(g: {
|
|||||||
enclosureId: g.enclosureId ?? '',
|
enclosureId: g.enclosureId ?? '',
|
||||||
litterId: g.litterId ?? '',
|
litterId: g.litterId ?? '',
|
||||||
originContactId: g.originContactId ?? '',
|
originContactId: g.originContactId ?? '',
|
||||||
|
originBreeder: g.originBreeder ?? '',
|
||||||
receiverContactId: g.receiverContactId ?? '',
|
receiverContactId: g.receiverContactId ?? '',
|
||||||
genotype: g.genotype ?? '',
|
genotype: g.genotype ?? '',
|
||||||
notes: g.notes ?? '',
|
notes: g.notes ?? '',
|
||||||
isResident: g.isResident ?? true,
|
isResident: g.isResident ?? true,
|
||||||
|
isDeaf: g.isDeaf ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,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(), [])
|
||||||
@@ -167,10 +178,12 @@ export default function GerbilFormPage() {
|
|||||||
enclosureId: nn(form.enclosureId),
|
enclosureId: nn(form.enclosureId),
|
||||||
litterId: nn(form.litterId),
|
litterId: nn(form.litterId),
|
||||||
originContactId: nn(form.originContactId),
|
originContactId: nn(form.originContactId),
|
||||||
|
originBreeder: nn(form.originBreeder),
|
||||||
receiverContactId: nn(form.receiverContactId),
|
receiverContactId: nn(form.receiverContactId),
|
||||||
genotype: nn(form.genotype),
|
genotype: nn(form.genotype),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
isResident: form.isResident,
|
isResident: form.isResident,
|
||||||
|
isDeaf: form.isDeaf,
|
||||||
}
|
}
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
if (result.ok) navigate(`/rennmaeuse/${result.value.id}`)
|
||||||
@@ -187,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>
|
||||||
<input
|
<div className="namegen-name-row">
|
||||||
className="input"
|
<input
|
||||||
value={form.name}
|
id="gerbil-name"
|
||||||
onChange={(e) => set('name', e.target.value)}
|
className="input"
|
||||||
aria-invalid={Boolean(errors.name)}
|
value={form.name}
|
||||||
/>
|
onChange={(e) => set('name', e.target.value)}
|
||||||
|
aria-invalid={Boolean(errors.name)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => setShowNameSuggest((v) => !v)}
|
||||||
|
>
|
||||||
|
{de.namegen.button}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.name && <small className="error-text">{errors.name}</small>}
|
{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>
|
||||||
@@ -326,6 +356,16 @@ export default function GerbilFormPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>{t.fields.receiver}</span>
|
<span>{t.fields.receiver}</span>
|
||||||
<select
|
<select
|
||||||
@@ -360,6 +400,21 @@ export default function GerbilFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</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}>
|
<label className="field field--check" title={t.form.isResidentHint}>
|
||||||
<span>{t.form.isResidentLabel}</span>
|
<span>{t.form.isResidentLabel}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
import { formatDate, genderLabel, statusLabel } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
import './gerbils.css'
|
import './gerbils.css'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@@ -82,6 +83,14 @@ export default function GerbilsPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
const items = gerbils.data?.items ?? []
|
const items = gerbils.data?.items ?? []
|
||||||
|
|
||||||
|
// UX-MOBILE-1: count non-default filter values for the badge.
|
||||||
|
const activeFilterCount =
|
||||||
|
(status !== 'Active' ? 1 : 0) +
|
||||||
|
(gender !== '' ? 1 : 0) +
|
||||||
|
(colorVarietyId !== '' ? 1 : 0) +
|
||||||
|
(originBreeder !== '' ? 1 : 0) +
|
||||||
|
(showExternal ? 1 : 0)
|
||||||
|
|
||||||
// Multi-select bulk "Zur Abgabe stellen".
|
// Multi-select bulk "Zur Abgabe stellen".
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const toggleSelect = (id: string) =>
|
const toggleSelect = (id: string) =>
|
||||||
@@ -118,15 +127,20 @@ export default function GerbilsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
<input
|
searchField={
|
||||||
type="search"
|
<input
|
||||||
className="input"
|
type="search"
|
||||||
placeholder={t.searchPlaceholder}
|
className="input"
|
||||||
value={search}
|
placeholder={t.searchPlaceholder}
|
||||||
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
value={search}
|
||||||
aria-label={t.fields.name}
|
onChange={(e) => onFilterChange(setSearch)(e.target.value)}
|
||||||
/>
|
aria-label={t.fields.name}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
activeCount={activeFilterCount}
|
||||||
|
onReset={resetFilters}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filters.status}</span>
|
<span>{t.filters.status}</span>
|
||||||
<select
|
<select
|
||||||
@@ -200,10 +214,7 @@ export default function GerbilsPage() {
|
|||||||
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn" onClick={resetFilters}>
|
</FilterPanel>
|
||||||
{t.filters.reset}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
{gerbils.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{gerbils.error && (
|
{gerbils.error && (
|
||||||
@@ -245,11 +256,11 @@ export default function GerbilsPage() {
|
|||||||
className="gerbil-row__check"
|
className="gerbil-row__check"
|
||||||
checked={selected.has(g.id)}
|
checked={selected.has(g.id)}
|
||||||
onChange={() => toggleSelect(g.id)}
|
onChange={() => toggleSelect(g.id)}
|
||||||
aria-label={g.name}
|
aria-label={g.name || t.nameless}
|
||||||
/>
|
/>
|
||||||
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
<Link to={`/rennmaeuse/${g.id}`} className="gerbil-card">
|
||||||
<span className="gerbil-card__name">
|
<span className="gerbil-card__name">
|
||||||
{g.name}
|
{g.name || t.nameless}
|
||||||
{g.isResident === false && (
|
{g.isResident === false && (
|
||||||
<span className="badge badge--external" title={t.externalTitle}>
|
<span className="badge badge--external" title={t.externalTitle}>
|
||||||
{t.externalBadge}
|
{t.externalBadge}
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ const sections: Section[] = [
|
|||||||
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>„{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
|
<p>Die Navigation erreichst du unten (Smartphone) oder links (Laptop). Mit <strong>„{nav.more}"</strong> öffnest du alle weiteren Bereiche.</p>
|
||||||
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
|
<p>Empfohlene Reihenfolge beim ersten Mal:</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Becken anlegen (wo leben deine Tiere?)</li>
|
<li>Gehege anlegen (wo leben deine Tiere?)</li>
|
||||||
<li>Rennmäuse anlegen mit Foto</li>
|
<li>Rennmäuse anlegen mit Foto</li>
|
||||||
<li>Würfe eintragen</li>
|
<li>Würfe eintragen</li>
|
||||||
<li>Genetik erkunden</li>
|
<li>Genetik erkunden</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p className="hilfe-hint">Tipp: Lege zuerst die Becken an — dann kannst du beim Erfassen der Tiere direkt das richtige Becken auswählen.</p>
|
<p className="hilfe-hint">Tipp: Lege zuerst die Gehege an — dann kannst du beim Erfassen der Tiere direkt das richtige Gehege auswählen.</p>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -53,7 +53,7 @@ const sections: Section[] = [
|
|||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>„{g.newButton}"</strong> legst du ein neues Tier an.</p>
|
<p>Unter <strong>„{nav.gerbils}"</strong> siehst du alle deine Rennmäuse. Mit <strong>„{g.newButton}"</strong> legst du ein neues Tier an.</p>
|
||||||
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>„{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Becken und Herkunft eintragen.</p>
|
<p>Pflichtfelder sind <strong>„{g.fields.name}"</strong> und <strong>„{g.fields.gender}"</strong>. Optional kannst du Geburtsdatum, Farbschlag, Genotyp, Gehege und Herkunft eintragen.</p>
|
||||||
<p>Auf der Detailseite findest du drei Tabs:</p>
|
<p>Auf der Detailseite findest du drei Tabs:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>„{g.detail.tabs.photos}"</strong> — Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
|
<li><strong>„{g.detail.tabs.photos}"</strong> — Fotos hochladen; das erste Foto wird als Profilfoto angezeigt.</li>
|
||||||
@@ -69,9 +69,9 @@ const sections: Section[] = [
|
|||||||
title: t.sections.becken,
|
title: t.sections.becken,
|
||||||
content: (
|
content: (
|
||||||
<>
|
<>
|
||||||
<p>Unter <strong>„{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Becken an (nur Name ist Pflicht).</p>
|
<p>Unter <strong>„{nav.enclosures}"</strong> verwaltest du deine Gehege. Mit <strong>„{b.newButton}"</strong> legst du ein neues Gehege an (nur Name ist Pflicht).</p>
|
||||||
<p>Die Detailseite zeigt dir die aktuellen <strong>„{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
|
<p>Die Detailseite zeigt dir die aktuellen <strong>„{b.occupancy.title}"</strong> — mit Links direkt zur Tierakte. So siehst du auf einen Blick, wer wo lebt.</p>
|
||||||
<p>Ein Becken lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
|
<p>Ein Gehege lässt sich nur löschen, wenn es leer ist — das schützt vor versehentlichem Datenverlust.</p>
|
||||||
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
|
<p>Unter <strong>„{nav.contacts}"</strong> kannst du Züchter, Käufer und andere Kontakte anlegen. Diese lassen sich dann als Herkunft oder Abnehmer bei Tieren eintragen.</p>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { de } from '../strings/de'
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2>{de.pages.home.title}</h2>
|
|
||||||
<p>{de.pages.home.intro}</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
@@ -335,7 +335,11 @@ export default function StammbaumPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="stammbaum-hint">{t.tapHint}</p>
|
<ul className="stammbaum-hints">
|
||||||
|
<li>{t.tapHint}</li>
|
||||||
|
<li>{t.hintName}</li>
|
||||||
|
<li>{t.hintExpand}</li>
|
||||||
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
||||||
@@ -377,7 +381,13 @@ 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>
|
<Link
|
||||||
|
to={`/rennmaeuse/${g.id}`}
|
||||||
|
className="pedigree-card__nametext"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{g.name || de.pages.gerbils.nameless}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{farbschlag && (
|
{farbschlag && (
|
||||||
<span
|
<span
|
||||||
@@ -439,7 +449,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 +504,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>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
|||||||
import type { Litter } from '../api/types'
|
import type { Litter } from '../api/types'
|
||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate } from '../format/labels'
|
import { formatDate } from '../format/labels'
|
||||||
|
import { FilterPanel } from '../components/FilterPanel'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -111,7 +112,10 @@ export default function WuerfeListPage() {
|
|||||||
|
|
||||||
{tab === 'litters' && (
|
{tab === 'litters' && (
|
||||||
<>
|
<>
|
||||||
<div className="filters">
|
<FilterPanel
|
||||||
|
activeCount={year !== '' ? 1 : 0}
|
||||||
|
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.filterYear}</span>
|
<span>{t.filterYear}</span>
|
||||||
<select
|
<select
|
||||||
@@ -136,7 +140,7 @@ export default function WuerfeListPage() {
|
|||||||
<option value="dateAsc">{t.sort.dateAsc}</option>
|
<option value="dateAsc">{t.sort.dateAsc}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</FilterPanel>
|
||||||
|
|
||||||
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
{litters.loading && <p className="muted">{de.common.loading}</p>}
|
||||||
{litters.error && (
|
{litters.error && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -34,10 +34,15 @@
|
|||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stammbaum-hint {
|
.stammbaum-hints {
|
||||||
font-size: 0.8rem;
|
list-style: none;
|
||||||
color: var(--color-text-muted);
|
padding: 0;
|
||||||
margin: 0.4rem 0 0;
|
margin: 0.4rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.1rem 1rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
/* ── Zeichenfläche ────────────────────────────────────────────── */
|
||||||
@@ -134,6 +139,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__nametext:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pedigree-card__sex--male {
|
.pedigree-card__sex--male {
|
||||||
@@ -169,20 +180,27 @@
|
|||||||
.pedigree-card__expand {
|
.pedigree-card__expand {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid var(--color-accent);
|
border: none;
|
||||||
background: var(--color-accent-soft);
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 1.05rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pedigree-card__expand:hover {
|
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 5px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card__expand:hover,
|
||||||
|
.pedigree-card__expand:focus-visible {
|
||||||
|
opacity: 0.82;
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
/* ── Druckansicht (Ahnentafel als CSS-Grid, Hunde-Zertifikat-Optik) ──
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ export const de = {
|
|||||||
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
|
tagline: 'Verwaltung für Rennmäuse, Würfe und Züchter',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Start',
|
|
||||||
gerbils: 'Rennmäuse',
|
gerbils: 'Rennmäuse',
|
||||||
litters: 'Würfe',
|
litters: 'Würfe',
|
||||||
breeders: 'Züchter',
|
breeders: 'Züchter',
|
||||||
genetics: 'Genetik',
|
genetics: 'Genetik',
|
||||||
// FEAT-2 (Oscar): Becken + Kontakte
|
// FEAT-2 (Oscar): Becken + Kontakte (GEHEGE-RENAME: Becken → Gehege)
|
||||||
enclosures: 'Becken',
|
enclosures: 'Gehege',
|
||||||
contacts: 'Kontakte',
|
contacts: 'Kontakte',
|
||||||
// FEAT-7 (Kelly): Statistik
|
// FEAT-7 (Kelly): Statistik
|
||||||
statistics: 'Statistik',
|
statistics: 'Statistik',
|
||||||
@@ -37,13 +36,9 @@ export const de = {
|
|||||||
website: 'Webseite',
|
website: 'Webseite',
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
home: {
|
|
||||||
title: 'Willkommen',
|
|
||||||
intro:
|
|
||||||
'Hier verwaltest du deine Rennmäuse, Würfe und Züchter. Wähle einen Bereich aus der Navigation.',
|
|
||||||
},
|
|
||||||
gerbils: {
|
gerbils: {
|
||||||
title: 'Rennmäuse',
|
title: 'Rennmäuse',
|
||||||
|
nameless: '(ohne Namen)',
|
||||||
// Liste
|
// Liste
|
||||||
newButton: 'Neue Rennmaus',
|
newButton: 'Neue Rennmaus',
|
||||||
empty: 'Keine Rennmäuse gefunden.',
|
empty: 'Keine Rennmäuse gefunden.',
|
||||||
@@ -82,9 +77,11 @@ export const de = {
|
|||||||
causeOfDeath: 'Todesursache',
|
causeOfDeath: 'Todesursache',
|
||||||
goHomeDate: 'Abgabedatum',
|
goHomeDate: 'Abgabedatum',
|
||||||
litter: 'Wurf',
|
litter: 'Wurf',
|
||||||
enclosure: 'Becken',
|
enclosure: 'Gehege',
|
||||||
colorVariety: 'Farbschlag',
|
colorVariety: 'Farbschlag',
|
||||||
origin: 'Herkunft',
|
origin: 'Herkunft',
|
||||||
|
originBreeder: 'Herkunft (Freitext)',
|
||||||
|
isDeaf: 'Gehörlos',
|
||||||
receiver: 'Abnehmer',
|
receiver: 'Abnehmer',
|
||||||
genotype: 'Genotyp',
|
genotype: 'Genotyp',
|
||||||
notes: 'Notizen',
|
notes: 'Notizen',
|
||||||
@@ -108,6 +105,11 @@ export const de = {
|
|||||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||||
moreData: 'Weitere Daten',
|
moreData: 'Weitere Daten',
|
||||||
|
// ANIMAL-LITTERS: Würfe als Elternteil
|
||||||
|
parentLittersTitle: 'Würfe als Elternteil',
|
||||||
|
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
||||||
|
parentLittersRoleVater: 'Vater',
|
||||||
|
parentLittersRoleMutter: 'Mutter',
|
||||||
},
|
},
|
||||||
// Formular (anlegen/bearbeiten)
|
// Formular (anlegen/bearbeiten)
|
||||||
form: {
|
form: {
|
||||||
@@ -119,6 +121,9 @@ export const de = {
|
|||||||
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
// BESTAND-FILTER: Zugehörigkeit zum eigenen Bestand (sonst externe Ahne)
|
||||||
isResidentLabel: 'Gehört zum eigenen Bestand',
|
isResidentLabel: 'Gehört zum eigenen Bestand',
|
||||||
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
isResidentHint: 'Abwählen für externe Ahnen, die nur für den Stammbaum erfasst sind.',
|
||||||
|
isDeafUnknown: 'Unbekannt',
|
||||||
|
isDeafYes: 'Ja',
|
||||||
|
isDeafNo: 'Nein',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -253,8 +258,8 @@ export const de = {
|
|||||||
markMultiAction: 'Ausgewählte zur Abgabe stellen',
|
markMultiAction: 'Ausgewählte zur Abgabe stellen',
|
||||||
grouping: {
|
grouping: {
|
||||||
title: 'Gruppen',
|
title: 'Gruppen',
|
||||||
byEnclosure: 'Nach Becken vorgeschlagen',
|
byEnclosure: 'Nach Gehege vorgeschlagen',
|
||||||
ungrouped: 'Ohne Becken',
|
ungrouped: 'Ohne Gehege',
|
||||||
moveTo: 'Verschieben nach',
|
moveTo: 'Verschieben nach',
|
||||||
newGroup: 'Neue Gruppe',
|
newGroup: 'Neue Gruppe',
|
||||||
groupLabel: 'Gruppe',
|
groupLabel: 'Gruppe',
|
||||||
@@ -298,12 +303,12 @@ export const de = {
|
|||||||
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
|
notConfigured: 'KI-Funktion: API-Schlüssel noch nicht konfiguriert.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-2 (Oscar): Becken (Enclosures) ──
|
// ── FEAT-2 (Oscar): Gehege (Enclosures) — GEHEGE-RENAME: Becken → Gehege ──
|
||||||
becken: {
|
becken: {
|
||||||
title: 'Becken',
|
title: 'Gehege',
|
||||||
newButton: 'Neues Becken',
|
newButton: 'Neues Gehege',
|
||||||
empty: 'Keine Becken gefunden.',
|
empty: 'Keine Gehege gefunden.',
|
||||||
countLabel: 'Becken',
|
countLabel: 'Gehege',
|
||||||
searchPlaceholder: 'Name suchen …',
|
searchPlaceholder: 'Name suchen …',
|
||||||
fields: {
|
fields: {
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -311,18 +316,18 @@ export const de = {
|
|||||||
},
|
},
|
||||||
occupancy: {
|
occupancy: {
|
||||||
title: 'Bewohner',
|
title: 'Bewohner',
|
||||||
empty: 'Dieses Becken ist derzeit leer.',
|
empty: 'Dieses Gehege ist derzeit leer.',
|
||||||
countOne: 'Tier',
|
countOne: 'Tier',
|
||||||
countMany: 'Tiere',
|
countMany: 'Tiere',
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
edit: 'Bearbeiten',
|
edit: 'Bearbeiten',
|
||||||
back: 'Zurück zur Liste',
|
back: 'Zurück zur Liste',
|
||||||
notFound: 'Dieses Becken wurde nicht gefunden.',
|
notFound: 'Dieses Gehege wurde nicht gefunden.',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
createTitle: 'Neues Becken anlegen',
|
createTitle: 'Neues Gehege anlegen',
|
||||||
editTitle: 'Becken bearbeiten',
|
editTitle: 'Gehege bearbeiten',
|
||||||
save: 'Speichern',
|
save: 'Speichern',
|
||||||
cancel: 'Abbrechen',
|
cancel: 'Abbrechen',
|
||||||
saving: 'Speichern …',
|
saving: 'Speichern …',
|
||||||
@@ -332,9 +337,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
action: 'Löschen',
|
action: 'Löschen',
|
||||||
confirmMessage: 'Soll dieses Becken wirklich gelöscht werden?',
|
confirmMessage: 'Soll dieses Gehege wirklich gelöscht werden?',
|
||||||
conflict:
|
conflict:
|
||||||
'Becken ist nicht leer — bitte zuerst alle Tiere in ein anderes Becken umsetzen.',
|
'Gehege ist nicht leer — bitte zuerst alle Tiere in ein anderes Gehege umsetzen.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
|
// ── FEAT-4 (Kelly): Stammbaum (Ahnentafel) ──
|
||||||
@@ -350,6 +355,9 @@ export const de = {
|
|||||||
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
/** Auf Karten am Rand: weitere Vorfahren nachladen. */
|
||||||
expand: 'Vorfahren laden',
|
expand: 'Vorfahren laden',
|
||||||
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
tapHint: 'Tippe auf ein Tier, um dessen Stammbaum anzuzeigen.',
|
||||||
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
zoomIn: 'Vergrößern',
|
zoomIn: 'Vergrößern',
|
||||||
zoomOut: 'Verkleinern',
|
zoomOut: 'Verkleinern',
|
||||||
zoomFit: 'Ansicht einpassen',
|
zoomFit: 'Ansicht einpassen',
|
||||||
@@ -731,7 +739,7 @@ export const de = {
|
|||||||
sections: {
|
sections: {
|
||||||
ersteSchritte: 'Erste Schritte',
|
ersteSchritte: 'Erste Schritte',
|
||||||
tiere: 'Rennmäuse anlegen & Fotos',
|
tiere: 'Rennmäuse anlegen & Fotos',
|
||||||
becken: 'Becken',
|
becken: 'Gehege',
|
||||||
wuerfe: 'Würfe & Jungtiere',
|
wuerfe: 'Würfe & Jungtiere',
|
||||||
genetik: 'Probeverpaarung & Genetik',
|
genetik: 'Probeverpaarung & Genetik',
|
||||||
stammbaum: 'Stammbaum',
|
stammbaum: 'Stammbaum',
|
||||||
@@ -779,9 +787,9 @@ export const de = {
|
|||||||
},
|
},
|
||||||
unknownFarbschlag: 'Unbekannter Farbschlag',
|
unknownFarbschlag: 'Unbekannter Farbschlag',
|
||||||
},
|
},
|
||||||
// ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
|
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ──
|
||||||
// Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
|
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt).
|
||||||
// Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
|
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt).
|
||||||
character: {
|
character: {
|
||||||
sectionTitle: 'Charakter & Eigenschaften',
|
sectionTitle: 'Charakter & Eigenschaften',
|
||||||
noteLabel: 'Notizen zum Charakter',
|
noteLabel: 'Notizen zum Charakter',
|
||||||
@@ -789,24 +797,74 @@ export const de = {
|
|||||||
save: 'Charakter speichern',
|
save: 'Charakter speichern',
|
||||||
saved: 'Charakter gespeichert.',
|
saved: 'Charakter gespeichert.',
|
||||||
none: 'Noch keine Eigenschaften ausgewählt.',
|
none: 'Noch keine Eigenschaften ausgewählt.',
|
||||||
traits: [
|
warnLabel: '⚠ Warnsignal',
|
||||||
{ key: 'zutraulich', label: 'zutraulich' },
|
traitCategories: [
|
||||||
{ key: 'handzahm', label: 'handzahm' },
|
{
|
||||||
{ key: 'neugierig', label: 'neugierig' },
|
category: 'Sozialverhalten',
|
||||||
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
traits: [
|
||||||
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
{ key: 'dominant', label: 'dominant (Leittier)' },
|
||||||
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' },
|
||||||
{ key: 'verschmust', label: 'verschmust' },
|
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' },
|
||||||
{ key: 'eigenstaendig', label: 'eigenständig' },
|
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true },
|
||||||
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
],
|
||||||
{ key: 'futterfreudig', label: 'futterfreudig' },
|
},
|
||||||
{ key: 'buddelt', label: 'buddelt gern' },
|
{
|
||||||
{ key: 'klettert', label: 'klettert gern' },
|
category: 'Eignung & Umgang',
|
||||||
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
traits: [
|
||||||
{ key: 'vertraeglich', label: 'gut verträglich' },
|
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' },
|
||||||
{ key: 'schreckhaft', label: 'schreckhaft' },
|
{ key: 'erfahrene-halter', label: 'für erfahrene Halter' },
|
||||||
|
{ key: 'beobachtungstier', label: 'reines Beobachtungstier' },
|
||||||
|
{ key: 'familiengeeignet', label: 'familiengeeignet / stressresistent' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Hobbys & Eigenarten',
|
||||||
|
traits: [
|
||||||
|
{ key: 'futterfreudig', label: 'futterfreudig' },
|
||||||
|
{ key: 'buddelt', label: 'buddelt gern' },
|
||||||
|
{ key: 'klettert', label: 'klettert gern' },
|
||||||
|
{ key: 'laufrad', label: 'läuft gern im Laufrad' },
|
||||||
|
{ key: 'schredder', label: 'Schredder-Meister' },
|
||||||
|
{ key: 'nestbauer', label: 'Nestbauer / Architekt' },
|
||||||
|
{ key: 'territorial', label: 'territorial', warn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Wesen & Temperament',
|
||||||
|
traits: [
|
||||||
|
{ key: 'zutraulich', label: 'zutraulich' },
|
||||||
|
{ key: 'handzahm', label: 'handzahm' },
|
||||||
|
{ key: 'neugierig', label: 'neugierig' },
|
||||||
|
{ key: 'aufgeschlossen', label: 'aufgeschlossen' },
|
||||||
|
{ key: 'ruhig', label: 'ruhig / ausgeglichen' },
|
||||||
|
{ key: 'lebhaft', label: 'lebhaft / aktiv' },
|
||||||
|
{ key: 'verschmust', label: 'verschmust' },
|
||||||
|
{ key: 'eigenstaendig', label: 'eigenständig' },
|
||||||
|
{ key: 'vertraeglich', label: 'gut verträglich' },
|
||||||
|
{ key: 'schreckhaft', label: 'schreckhaft' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
|
||||||
|
filterPanel: {
|
||||||
|
toggleButton: 'Filter',
|
||||||
|
resetButton: 'Filter zurücksetzen',
|
||||||
|
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
|
||||||
|
|||||||
@@ -84,6 +84,41 @@
|
|||||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Little Runner's Big Ben",
|
||||||
|
"dob": "03.02.2020",
|
||||||
|
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
||||||
|
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vance Jr. von den Kleinen Chaoten",
|
||||||
|
"dob": "10.04.2022",
|
||||||
|
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
||||||
|
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Skarlett von den Kleinen Chaoten",
|
||||||
|
"dob": "14.07.2013",
|
||||||
|
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
||||||
|
"dateOfDeath": "17.04.2016",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kazu von den Kleinen Chaoten",
|
||||||
|
"dob": "23.04.2013",
|
||||||
|
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||||
|
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hanami von den Kleinen Chaoten",
|
||||||
|
"dob": "10.09.2015",
|
||||||
|
"decision": "death date = 12.12.2019 (confirmed; the 14.01.2020 variant was wrong)",
|
||||||
|
"dateOfDeath": "12.12.2019",
|
||||||
|
"source": "Julian 2026-06-07 — HUMANQUESTION D5/D6 (letzter D6-Konflikt)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ def norm_zucht(z):
|
|||||||
if not z:
|
if not z:
|
||||||
return ""
|
return ""
|
||||||
n = z.lower()
|
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"\b(zucht|privatzucht|der|die|den|des|dem|von|of)\b", " ", n)
|
||||||
n = re.sub(r"[^a-z0-9äöüß ]", " ", 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()]
|
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)
|
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):
|
def canon_pair(raw):
|
||||||
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
"""Full raw name -> (normalised call-name, canonical zucht)."""
|
||||||
name, zucht = split_name_zucht(raw)
|
name, zucht = split_name_zucht(raw)
|
||||||
@@ -962,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"]]
|
||||||
|
|||||||
312
tools/import/extract_docx.py
Normal file
312
tools/import/extract_docx.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""FEAT-8d Stage 1 — Wurfchronik-Detail-Dokument (.docx) extrahieren.
|
||||||
|
|
||||||
|
Liest 'Wurfchronik der Kleinen Chaoten im Detail.docx' (Word/XML, stdlib-Python,
|
||||||
|
kein pip) und erzeugt:
|
||||||
|
output/docx_litters.json — Wurf-Kopfdaten (WS-Code, DOB, Eltern, Notiz)
|
||||||
|
output/docx_animals.json — Tier-Zeilen (Name, Farbe, Abnehmer, ABD, Tod)
|
||||||
|
|
||||||
|
Format der Ausgabe ist so gestaltet, dass ImportDocxService.cs in C# direkt
|
||||||
|
darüber laden kann. Idempotent: mehrfaches Ausführen überschreibt denselben Output.
|
||||||
|
|
||||||
|
Bekannte Sonderwerte im Dokument:
|
||||||
|
ZT = Zucht-Tier (bleibt in Zucht, kein externer Abnehmer)
|
||||||
|
BLEIBT = vorläufig beim Züchter
|
||||||
|
FREI = noch verfügbar
|
||||||
|
VG: = Verpaarungs-Geschichte (bisherige Partner; nicht als Abnehmer werten)
|
||||||
|
RG: = Rückgabe
|
||||||
|
BEW = Bewerbung (Adoptionsinteressent in Prüfung)
|
||||||
|
-- ??? = Platzhalter, kein echter Name
|
||||||
|
|
||||||
|
Feld 'gender': '' = weiblich (kein Marker), '*' auf Farbschlag oder 'G'-Spalte = männlich.
|
||||||
|
|
||||||
|
Ausführung: python extract_docx.py [--docx PFAD]
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
DEFAULT_DOCX = os.path.join(
|
||||||
|
r"C:\Users\gulum\dev",
|
||||||
|
"Wurfchronik der Kleinen Chaoten im Detail.docx",
|
||||||
|
)
|
||||||
|
OUT = os.path.join(HERE, "output")
|
||||||
|
|
||||||
|
# --- Regex patterns -------------------------------------------------------
|
||||||
|
|
||||||
|
# Litter header paragraph (after whitespace-collapsing).
|
||||||
|
# Edge cases handled:
|
||||||
|
# - Dual birth date: "*16./17.03.2021"
|
||||||
|
# - WS without numerator: "WS: /5"
|
||||||
|
# - WS with trailing text: "WS: 4/4, davon 1 später..."
|
||||||
|
# - No space before WS: "...ChaotenWS: 2/4"
|
||||||
|
# Date part allows simple DD.MM.YYYY, dual-day (16./17.03.2021), or dual-month (31.05/*01.06.2023).
|
||||||
|
# We capture the LAST complete DD.MM.YYYY in the date token as the birth date.
|
||||||
|
_DATE_TOKEN = r"[\d./\*]+"
|
||||||
|
# Full litter header regex
|
||||||
|
LITTER_RE = re.compile(
|
||||||
|
r"([A-Za-z\d\-]*Wurf)\s*\*\s*(" + _DATE_TOKEN + r")"
|
||||||
|
r"\s*Von:\s*(.+?)\s*&\s*(.+?)\s*WS:\s*(\d*\s*/\s*\d+)"
|
||||||
|
r"(?:[,\s].*?)?(?:Notiz:\s*(.*?))?$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Used to extract the canonical date from a date token like "31.05/*01.06.2023"
|
||||||
|
_LAST_DATE_RE = re.compile(r"(\d{1,2}\.\d{2}\.\d{4})(?![\d.])")
|
||||||
|
# Death/adoption date at start of combined T.D column: "16.09.23Tumor am After"
|
||||||
|
DATE_START_RE = re.compile(r"^(\d{1,2}\.\d{1,2}\.\d{2,4})\s*(.*)")
|
||||||
|
# Partner birth date: "Crow (*25.12.20)" or "Tom (*05.01.21)"
|
||||||
|
PARTNER_DOB_RE = re.compile(r"\(\s*\*\s*(\d{2}\.\d{2}\.\d{2,4})\s*\)")
|
||||||
|
# Special-value sentinel names to skip
|
||||||
|
PLACEHOLDER_NAMES = {"--", "???", ""}
|
||||||
|
INTERNAL_TOKENS = {"ZT", "BLEIBT", "FREI", "VG:", "VG*:", "RG:", "BEW"}
|
||||||
|
|
||||||
|
|
||||||
|
def _norm_dob(d: str) -> str:
|
||||||
|
"""Normalise German date to DD.MM.YYYY."""
|
||||||
|
if not d:
|
||||||
|
return ""
|
||||||
|
p = d.strip().split(".")
|
||||||
|
if len(p) == 3:
|
||||||
|
y = p[2].strip()
|
||||||
|
if len(y) == 2:
|
||||||
|
y = "20" + y
|
||||||
|
return f"{p[0].zfill(2)}.{p[1].zfill(2)}.{y}"
|
||||||
|
return d.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_text(cell_xml: str) -> str:
|
||||||
|
"""Strip XML from a <w:tc> cell and return clean text."""
|
||||||
|
t = re.sub(r"<[^>]+>", "", cell_xml)
|
||||||
|
t = t.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
t = t.replace("'", "'").replace(""", '"')
|
||||||
|
return re.sub(r"\s+", " ", t).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal(value: str) -> bool:
|
||||||
|
"""True if the owner/name field holds an internal sentinel, not a real person."""
|
||||||
|
v = value.strip()
|
||||||
|
return v in INTERNAL_TOKENS or any(v.startswith(tok) for tok in INTERNAL_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(docx_path: str):
|
||||||
|
"""Parse the docx and return (litters, animals) lists."""
|
||||||
|
with zipfile.ZipFile(docx_path) as z:
|
||||||
|
xml = z.read("word/document.xml").decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# ---- Paragraphs → litter header blocks ----
|
||||||
|
paras = re.findall(r"<w:p[ >].*?</w:p>", xml, re.DOTALL)
|
||||||
|
para_texts = []
|
||||||
|
for p in paras:
|
||||||
|
t = re.sub(r"<[^>]+>", "", p)
|
||||||
|
t = t.replace("&", "&").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
if t:
|
||||||
|
para_texts.append(t)
|
||||||
|
|
||||||
|
litters: list[dict] = []
|
||||||
|
current_ws: str = ""
|
||||||
|
current_litter_dob: str = ""
|
||||||
|
|
||||||
|
# Build a WS-code → litter index for assigning animals
|
||||||
|
ws_to_idx: dict[str, int] = {}
|
||||||
|
|
||||||
|
for para in para_texts:
|
||||||
|
m = LITTER_RE.search(para)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
litter_id = m.group(1).strip()
|
||||||
|
dob_raw = m.group(2).strip()
|
||||||
|
mother_raw = m.group(3).strip()
|
||||||
|
father_raw = m.group(4).strip()
|
||||||
|
ws_raw = m.group(5).replace(" ", "")
|
||||||
|
note = (m.group(6) or "").strip()
|
||||||
|
|
||||||
|
# For dual-date tokens like "31.05/*01.06.2023", take the last full date.
|
||||||
|
last_dates = _LAST_DATE_RE.findall(dob_raw)
|
||||||
|
dob_clean = _norm_dob(last_dates[-1] if last_dates else dob_raw)
|
||||||
|
|
||||||
|
litter = {
|
||||||
|
"litterId": litter_id,
|
||||||
|
"dob": dob_clean,
|
||||||
|
"motherName": mother_raw,
|
||||||
|
"fatherName": father_raw,
|
||||||
|
"wsCode": ws_raw,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
ws_to_idx[ws_raw] = len(litters)
|
||||||
|
litters.append(litter)
|
||||||
|
|
||||||
|
# ---- Tables → animal rows ----
|
||||||
|
# Each table sits after a litter-header paragraph; we sequence tables and
|
||||||
|
# litter headers together by their byte offset in the XML.
|
||||||
|
animals: list[dict] = []
|
||||||
|
|
||||||
|
# Build ordered sequence of (offset, type, data) events
|
||||||
|
events: list[tuple[int, str, any]] = []
|
||||||
|
for m in re.finditer(r"<w:p[ >].*?</w:p>", xml, re.DOTALL):
|
||||||
|
t = re.sub(r"<[^>]+>", "", m.group()).replace("&", "&").strip()
|
||||||
|
t = re.sub(r"\s+", " ", t).strip()
|
||||||
|
lm = LITTER_RE.search(t)
|
||||||
|
if lm:
|
||||||
|
ws = lm.group(5).replace(" ", "")
|
||||||
|
dob_tok = lm.group(2)
|
||||||
|
last = _LAST_DATE_RE.findall(dob_tok)
|
||||||
|
dob = _norm_dob(last[-1] if last else dob_tok)
|
||||||
|
events.append((m.start(), "litter", (ws, dob)))
|
||||||
|
|
||||||
|
for m in re.finditer(r"<w:tbl[ >].*?</w:tbl>", xml, re.DOTALL):
|
||||||
|
events.append((m.start(), "table", m.group()))
|
||||||
|
|
||||||
|
events.sort(key=lambda e: e[0])
|
||||||
|
|
||||||
|
active_ws = ""
|
||||||
|
active_dob = ""
|
||||||
|
|
||||||
|
for _, etype, edata in events:
|
||||||
|
if etype == "litter":
|
||||||
|
active_ws, active_dob = edata
|
||||||
|
elif etype == "table" and active_ws:
|
||||||
|
# Parse all rows in this table
|
||||||
|
rows = re.findall(r"<w:tr[ >].*?</w:tr>", edata, re.DOTALL)
|
||||||
|
for row in rows:
|
||||||
|
cells_xml = re.findall(r"<w:tc[ >].*?</w:tc>", row, re.DOTALL)
|
||||||
|
ct = [_cell_text(c) for c in cells_xml]
|
||||||
|
if not ct:
|
||||||
|
continue
|
||||||
|
# Skip header rows
|
||||||
|
if ct[0] == "G" and len(ct) > 1 and "Farbe" in ct[1]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Column positions: G | Farbe | Name | Partner | Abnehmer | ABD | T.D
|
||||||
|
# Some newer tables add ABGew between ABD and T.D (7 or 8 cols)
|
||||||
|
g_col = ct[0] if len(ct) > 0 else ""
|
||||||
|
farbe_raw = ct[1] if len(ct) > 1 else ""
|
||||||
|
name = ct[2] if len(ct) > 2 else ""
|
||||||
|
partner = ct[3] if len(ct) > 3 else ""
|
||||||
|
owner = ct[4] if len(ct) > 4 else ""
|
||||||
|
abd_raw = ct[5] if len(ct) > 5 else ""
|
||||||
|
# If 8 cols, col 6 = ABGew, col 7 = T.D; if 7 cols, col 6 = T.D
|
||||||
|
if len(ct) >= 8:
|
||||||
|
abgew = ct[6]
|
||||||
|
tod_raw = ct[7]
|
||||||
|
elif len(ct) >= 7:
|
||||||
|
abgew = ""
|
||||||
|
tod_raw = ct[6]
|
||||||
|
else:
|
||||||
|
abgew = ""
|
||||||
|
tod_raw = ""
|
||||||
|
|
||||||
|
# Skip placeholders
|
||||||
|
name = name.strip()
|
||||||
|
if name in PLACEHOLDER_NAMES:
|
||||||
|
continue
|
||||||
|
if not farbe_raw.strip() and not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Gender: explicit marker in G column, or * suffix on Farbschlag
|
||||||
|
is_male = bool(g_col.strip() == "*" or farbe_raw.endswith("*"))
|
||||||
|
farbschlag = farbe_raw.rstrip("*").strip()
|
||||||
|
|
||||||
|
# Owner: strip internal sentinels
|
||||||
|
owner_clean = owner.strip()
|
||||||
|
if _is_internal(owner_clean):
|
||||||
|
owner_clean = ""
|
||||||
|
# For multi-owner ("1.) Julia2.) RG:"), take first
|
||||||
|
m1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", owner_clean)
|
||||||
|
if m1:
|
||||||
|
owner_clean = m1.group(1).strip()
|
||||||
|
|
||||||
|
# ABD (Abgabe-Datum)
|
||||||
|
abgabe_date = _norm_dob(abd_raw.strip())
|
||||||
|
|
||||||
|
# T.D column: may start with a date followed by cause
|
||||||
|
death_date = ""
|
||||||
|
death_cause = ""
|
||||||
|
if tod_raw:
|
||||||
|
dm = DATE_START_RE.match(tod_raw.strip())
|
||||||
|
if dm:
|
||||||
|
death_date = _norm_dob(dm.group(1))
|
||||||
|
death_cause = dm.group(2).strip()
|
||||||
|
else:
|
||||||
|
death_cause = tod_raw.strip()
|
||||||
|
|
||||||
|
# Partner name and DOB
|
||||||
|
partner_clean = partner.strip()
|
||||||
|
partner_dob = ""
|
||||||
|
pdob_m = PARTNER_DOB_RE.search(partner_clean)
|
||||||
|
if pdob_m:
|
||||||
|
partner_dob = _norm_dob(pdob_m.group(1))
|
||||||
|
partner_clean = PARTNER_DOB_RE.sub("", partner_clean).strip()
|
||||||
|
# Strip VG:/ZT/etc. prefixes
|
||||||
|
partner_clean = re.sub(r"^(?:VG\*?:|ZT\s*)", "", partner_clean).strip()
|
||||||
|
# Take first partner in numbered list
|
||||||
|
pm1 = re.match(r"1\.\)\s*(.+?)(?:2\.\)|$)", partner_clean)
|
||||||
|
if pm1:
|
||||||
|
partner_clean = pm1.group(1).strip()
|
||||||
|
|
||||||
|
animals.append({
|
||||||
|
"wsCode": active_ws,
|
||||||
|
"litterDob": active_dob,
|
||||||
|
"name": name,
|
||||||
|
"farbschlag": farbschlag,
|
||||||
|
"gender": "male" if is_male else "female",
|
||||||
|
"owner": owner_clean,
|
||||||
|
"abgabeDate": abgabe_date,
|
||||||
|
"abgabeWeight": abgew.strip(),
|
||||||
|
"deathDate": death_date,
|
||||||
|
"deathCause": death_cause,
|
||||||
|
"partnerName": partner_clean,
|
||||||
|
"partnerDob": partner_dob,
|
||||||
|
})
|
||||||
|
|
||||||
|
return litters, animals
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="FEAT-8d docx extractor")
|
||||||
|
ap.add_argument("--docx", default=DEFAULT_DOCX,
|
||||||
|
help="Pfad zur 'im Detail.docx'")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isfile(args.docx):
|
||||||
|
print(f"Fehler: Datei nicht gefunden: {args.docx}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"Lese: {args.docx}")
|
||||||
|
litters, animals = extract(args.docx)
|
||||||
|
|
||||||
|
litters_path = os.path.join(OUT, "docx_litters.json")
|
||||||
|
animals_path = os.path.join(OUT, "docx_animals.json")
|
||||||
|
|
||||||
|
with open(litters_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(litters, f, ensure_ascii=False, indent=2)
|
||||||
|
with open(animals_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(animals, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
named = sum(1 for a in animals if a["name"])
|
||||||
|
with_owner = sum(1 for a in animals if a["owner"])
|
||||||
|
with_death = sum(1 for a in animals if a["deathDate"])
|
||||||
|
with_abgabe = sum(1 for a in animals if a["abgabeDate"])
|
||||||
|
|
||||||
|
print(f"Würfe: {len(litters)}")
|
||||||
|
print(f"Tiere: {len(animals)} (benannt: {named})")
|
||||||
|
print(f" mit Abnehmer: {with_owner}")
|
||||||
|
print(f" mit Abgabe-Dat: {with_abgabe}")
|
||||||
|
print(f" mit Tod-Datum: {with_death}")
|
||||||
|
print(f"Ausgabe: {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -8,7 +8,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
- Nach Zusammenführung (eindeutige Tiere): **621**
|
- Nach Zusammenführung (eindeutige Tiere): **621**
|
||||||
- davon mit Geburtsdatum: 326
|
- davon mit Geburtsdatum: 326
|
||||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 158
|
||||||
- Konflikte zur Klärung: **8**
|
- Konflikte zur Klärung: **5**
|
||||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **310**
|
||||||
- Fotos zugeordnet: **137**
|
- Fotos zugeordnet: **137**
|
||||||
- Würfe aus der Wurfchronik: **752**
|
- Würfe aus der Wurfchronik: **752**
|
||||||
@@ -25,12 +25,9 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|
|
||||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Valentino Firehearts Kids |
|
|
||||||
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
| Kazu von den Kleinen Chaoten | 23.04.2013 | Aa Cc[chm] DD e[f]e[f] Gg P Spsp // Aa Cc[chm] DD ee[f] UwUw PP Spsp | — | 03.09.2017 | Stammbaum von Akio Kids, Stammbaum von Vance |
|
||||||
| Enya von den Kleinen Chaoten | 01.11.2017 | Aa c[chm]c[chm] D- ee[-] G- P- spsp // Aa c[chm]c[chm] D- ee[-] Uwuw[d] P- spsp | — | — | Stammbaum von Alberto Kids, Stammbaum von Fire Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Stella Kids |
|
|
||||||
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
| Little Runner's Big Ben | 03.02.2020 | Aa Cc[chm] DD Ee Gg PP Spsp // Aa Cc[chm] DD Ee Gg Pp Spsp | — | 14.10.2023 | Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Fire Kids, Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Valentino Firehearts Kids, Stammbaum von Watarus Kids |
|
||||||
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
| Vance Jr. von den Kleinen Chaoten | 10.04.2022 | aa Cc[hm] Dd Ee gg P- Spsp // aa Cc[hm] Dd Ee gg P- spsp | Kohlfuchs, hell | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||||
| Zac gen. Action von den Kleinen Chaoten | 25.12.2020 | aa C- D- Ee G- Pp Spsp [DP] // aa CC D- Ee G- Pp Spsp [DP] | — | 31.01.2025 | Stammbaum von Goldfuchs Sp (Pikachu) Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
|
||||||
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
| Hanami von den Kleinen Chaoten | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp | — | 12.12.2019 // 14.01.2020 | Stammbaum von Kentucky, Stammbaum von Stella Kids |
|
||||||
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
| Skarlett v.d. Kleinen Chaoten | 14.07.2013 | / +2018, Aa Cc[chm] DD ee uw[d]uw[d] PP spsp // Aa Cc[chm] DD ee uw[d]uw[d] PP spsp | — | 17.04.2016 // 2018 | Stammbaum von Vance |
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
|
|
||||||
| Token | Vorkommen | Bedeutung (Vermutung) |
|
| Token | Vorkommen | Bedeutung (Vermutung) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/+` | 7 | ? |
|
| `/+` | 6 | ? |
|
||||||
| `-g` | 2 | ? |
|
| `-g` | 2 | ? |
|
||||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||||
| `Cc[]` | 1 | ? |
|
| `Cc[]` | 1 | ? |
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user