Compare commits

..

1 Commits

Author SHA1 Message Date
c452b69dd6 NAMEGEN-2-FE: 5 neue Namenskategorien (Disney, Pokémon, Englische/Kroatische Städte, US-Bundesstaaten)
- NAMEGEN_USAGES: +disney, +pokemon, +encities, +hrcities, +usstates (jetzt 10 Einträge)
- names.test.ts: Count-Test 5→10, neue toContain-Checks für alle 5 Codes

Gate: vitest 107/107, e2e 160/160, tsc clean
2026-06-06 21:48:08 +02:00
43 changed files with 691 additions and 6463 deletions

View File

@@ -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: git.rismer.de (Gitea Container Registry über HTTPS — keine insecure-registry-Konfig nötig) # Registry: truenas:13000 (internes Gitea Container Registry)
# Images: # Images:
# git.rismer.de/gulum/gerbilmanager-api:latest # truenas:13000/gulum/gerbilmanager-api:latest
# git.rismer.de/gulum/gerbilmanager-frontend:latest # truenas:13000/gulum/gerbilmanager-frontend:latest
name: CI name: CI
@@ -25,7 +25,7 @@ on:
- main - main
env: env:
REGISTRY: git.rismer.de REGISTRY: truenas:13000
REGISTRY_OWNER: gulum REGISTRY_OWNER: gulum
DOTNET_VERSION: "10.0.x" DOTNET_VERSION: "10.0.x"
NODE_VERSION: "22" NODE_VERSION: "22"

View File

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

View File

@@ -261,54 +261,6 @@ 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()
{ {
@@ -621,32 +573,27 @@ namespace GerbilManager.Tests
[Fact] [Fact]
public void SeedGen3g_existing_varieties_preserve_id_name_binding() public void SeedGen3g_existing_varieties_preserve_id_name_binding()
{ {
// CATALOG-RESEED regression: ID->Name bindings for all 66 existing entries must be // SEED-HELL bounce regression: the 61 existing entries must NOT change their
// stable. 4 new Dilute Fuchs varieties appended (IDs 67-70). CP-Fuchs (ID 58) and // Id->Name binding after GEN-3g. The 5 new entries (IDs 62-66) are appended.
// the CP-*-Hell entries (IDs 62-66) must survive with their original names intact. // A hand-assigned Gerbil.ColorVarietyId pointing to "CP-Fuchs" (ID 58) must
using var db = NewDb(); // EnsureCreated applies HasData including 70-entry seed // still map to CP-Fuchs after the migration runs (append-only, no rename-shift).
using var db = NewDb(); // EnsureCreated applies HasData including new 66-entry seed
// ID 58 = CP-Fuchs — must still be CP-Fuchs (no rename-shift from new entries) // ID 58 (index 57 in old catalog, 0-based) = CP-Fuchs — must still be CP-Fuchs
var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058"); var cpFuchsId = new Guid("00000000-0000-0000-0000-000000000058");
var cpFuchs = db.ColorVarieties.Find(cpFuchsId); var cpFuchs = db.ColorVarieties.Find(cpFuchsId);
Assert.NotNull(cpFuchs); Assert.NotNull(cpFuchs);
Assert.Equal("CP-Fuchs", cpFuchs!.Name); Assert.Equal("CP-Fuchs", cpFuchs!.Name);
// Existing CP-*-Hell entries (IDs 62-66) unchanged // New entries at IDs 62-66 exist with correct names
Assert.Equal("CP-Agouti-Hell", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000062"))!.Name); Assert.Equal("CP-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-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-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-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); 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) // Total count is exactly 66
Assert.Equal("Dilute Algierfuchs", db.ColorVarieties.Find(new Guid("00000000-0000-0000-0000-000000000067"))!.Name); Assert.Equal(66, db.ColorVarieties.Count());
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]

View File

@@ -8,8 +8,8 @@ using Microsoft.Extensions.Options;
namespace GerbilManager.Tests namespace GerbilManager.Tests
{ {
/// <summary> /// <summary>
/// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping, /// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
/// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path. /// fence strip), 503-not-configured path, upstream-error path.
/// </summary> /// </summary>
public class NameSuggestionTests public class NameSuggestionTests
{ {
@@ -26,14 +26,6 @@ namespace GerbilManager.Tests
Assert.Contains("origin", prompt); Assert.Contains("origin", prompt);
} }
[Fact]
public void SystemPrompt_erklärt_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildSystemPrompt();
Assert.Contains("thematischen Kategorien", prompt);
Assert.Contains("Erfinde KEINE Etymologie", prompt);
}
[Fact] [Fact]
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben() public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
{ {
@@ -41,10 +33,7 @@ namespace GerbilManager.Tests
Assert.Contains("6", prompt); Assert.Contains("6", prompt);
Assert.Contains("\"A\"", prompt); Assert.Contains("\"A\"", prompt);
Assert.Contains("weibliche", prompt); Assert.Contains("weibliche", prompt);
// codes are mapped to German descriptions Assert.Contains("norn,mythg", prompt);
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Griechische Mythologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
} }
[Fact] [Fact]
@@ -65,67 +54,6 @@ namespace GerbilManager.Tests
Assert.DoesNotContain("männliche", prompt); Assert.DoesNotContain("männliche", prompt);
} }
// ── NAMEGEN-2-BE: neue Themen-Kategorien ─────────────────────────────
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription)
{
Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry));
Assert.Equal(expectedDescription, entry.Description);
Assert.True(entry.Thematic);
}
[Theory]
[InlineData("disney", "Disney-Charaktere")]
[InlineData("pokemon", "Pokémon-Namen")]
[InlineData("encities", "Namen englischer Städte")]
[InlineData("hrcities", "Namen kroatischer Städte")]
[InlineData("usstates", "Namen von US-Bundesstaaten")]
public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription)
{
var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5);
Assert.Contains(expectedDescription, prompt);
Assert.Contains("Themen-Kategorien", prompt);
Assert.DoesNotContain("Kulturkreisen", prompt);
}
[Fact]
public void UserPrompt_trennt_etym_und_thematische_Kategorien()
{
var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4);
Assert.Contains("Nordische/Altnordische Etymologie", prompt);
Assert.Contains("Kulturkreisen", prompt);
Assert.Contains("Disney-Charaktere", prompt);
Assert.Contains("Themen-Kategorien", prompt);
}
[Fact]
public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis()
{
var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3);
Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt);
}
[Fact]
public async Task SuggestAsync_parst_thematische_Kategorie_Antwort()
{
var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]""";
var handler = new StubHandler(_ => Canned(payload));
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
var result = await service.SuggestAsync("D", null, "disney", 1);
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
Assert.NotNull(result.Suggestions);
Assert.Equal("Dumbo", result.Suggestions![0].Name);
Assert.Equal("Disney", result.Suggestions[0].Origin);
}
// ── JSON parsing ────────────────────────────────────────────────────── // ── JSON parsing ──────────────────────────────────────────────────────
[Fact] [Fact]

View File

@@ -282,97 +282,79 @@ public class ApplicationContext : DbContext
/// </summary> /// </summary>
private static void SeedColorVarieties(ModelBuilder modelBuilder) private static void SeedColorVarieties(ModelBuilder modelBuilder)
{ {
// CATALOG-RESEED (AR-5 / colorVarietySeed.backend.json): 70 entries, frozen symbols. // GEN-3g (Kevin): 5 CP-*-Hell variants appended at the end (IDs 62-66).
// ID = array_index + 1 (stable, never changes — live FKs safe). // Existing 61 entries are UNCHANGED (same ID->Name binding preserved).
// SortOrder comes from colorVarietySeed.backend.json (decoupled from ID). // SortOrder for new entries is appended; Kevin's frontend catalog handles
// New entries appended at end (IDs 67-70: Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs). // the interleaved display order via its own sortOrder values.
// GUARDRAIL: DO NOT reseed from colorVarietySeed.generated.json — it uses bracket display (string Name, string Genotype)[] catalog =
// notation (e[f]/c[chm]) which breaks CR-11 GenotypePotentiallyMatches() (frozen vs brackets).
(string Name, string Genotype, int SortOrder)[] catalog =
{ {
// --- C-locus white / partial albino (IDs 1-3) --- ("Pink Eyed White (PEW)", "AA chch DD EE GG pp spsp rere"),
("REW", "AA chch DD EE GG pp spsp rere", 0), ("Hermelin", "aa chch DD EE GG PP spsp rere"),
("Hermelin", "aa chch DD EE GG PP spsp rere", 1), ("Himalaya", "AA chch DD EE GG PP spsp rere"),
("Himalaya", "AA chch DD EE GG PP spsp rere", 2), ("Zobel", "aa cchmcchm DD EE gg PP spsp rere"),
// --- Zobel / colourpoint dark (ID 4) --- ("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere"),
("Zobel", "aa cchmcchm DD EE gg PP spsp rere", 3), ("Agouti", "AA CC DD EE GG PP spsp rere"),
// --- Schimmel family (IDs 5) --- ("Schwarz", "aa CC DD EE GG PP spsp rere"),
("Rotaugenschimmel", "AA CC DD efef GG pp spsp rere", 4), ("Silberagouti", "AA CC DD EE gg PP spsp rere"),
// --- Standard colours (IDs 6-15) --- ("Anthrazit", "aa CC DD EE gg PP spsp rere"),
("Agouti", "AA CC DD EE GG PP spsp rere", 5), ("Algierfuchs", "AA CC DD ee GG PP spsp rere"),
("Schwarz", "aa CC DD EE GG PP spsp rere", 6), ("Blau", "aa CC dd EE GG PP spsp rere"),
("Silberagouti","AA CC DD EE gg PP spsp rere", 7), ("Gold", "AA CC DD EE GG pp spsp rere"),
("Anthrazit", "aa CC DD EE gg PP spsp rere", 8), ("Platin", "aa CC DD EE GG pp spsp rere"),
("Algierfuchs", "AA CC DD ee GG PP spsp rere", 9), ("Goldfuchs", "AA CC DD ee GG pp spsp rere"),
("Blau", "aa CC dd EE GG PP spsp rere", 10), ("Rotfuchs", "aa CC DD ee GG pp spsp rere"),
("Gold", "AA CC DD EE GG pp spsp rere", 11), ("dd Gold", "AA CC dd EE GG pp spsp rere"),
("Platin", "aa CC DD EE GG pp spsp rere", 12), ("dd Platin", "aa CC dd EE GG pp spsp rere"),
("Goldfuchs", "AA CC DD ee GG pp spsp rere", 13), ("Altweiss (REW)", "aa CC DD EE gg pp spsp rere"),
("Rotfuchs", "aa CC DD ee GG pp spsp rere", 14), ("Apricot (Blassfuchs)", "AA CC DD ee gg pp spsp rere"),
// --- Dilute (dd) standard (IDs 16-17) --- ("Blaufuchs", "aa CC DD ee gg PP spsp rere"),
("Dilute Gold", "AA CC dd EE GG pp spsp rere", 15), ("C-Separator", "aa CC DD ee gg pp spsp rere"),
("Dilute Platin","aa CC dd EE GG pp spsp rere", 16), ("Elfenbein", "AA CC DD EE gg pp spsp rere"),
// --- REW / Apricot / misc C-locus (IDs 18-28) --- ("Kohlfuchs", "aa CC DD ee GG PP spsp rere"),
("Altweiss (REW)", "aa CC DD EE gg pp spsp rere", 17), ("Polarfuchs", "AA CC DD ee gg PP spsp rere"),
("Apricot (Blassfuchs)","AA CC DD ee gg pp spsp rere", 18), ("Saphir", "aa CC DD EE GG pp spsp rere"),
("Blaufuchs", "aa CC DD ee gg PP spsp rere", 19), ("Orangeschimmel", "AA CC DD efef GG PP spsp rere"),
("C-Separator", "aa CC DD ee gg pp spsp rere", 20), ("Topas", "AA CC DD EE GG pp spsp rere"),
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21), ("Platin-Hell", "aa CC DD EE GG pp spsp rere"),
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22), ("Agouti dd", "AA CC dd EE GG PP spsp rere"),
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23), ("Silberagouti dd", "AA CC dd EE gg PP spsp rere"),
("Saphir", "aa CC DD EE GG pp spsp rere", 24), ("Kohlfuchs dd", "aa CC dd ee GG PP spsp rere"),
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25), ("Anthrazit dd", "aa CC dd EE gg PP spsp rere"),
("Topas", "AA CC DD EE GG pp spsp rere", 26), ("Silberschimmel", "AA CC DD efef gg PP spsp rere"),
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27), ("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere"),
// --- Dilute (dd) varieties (IDs 29-32) --- ("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere"),
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28), ("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere"),
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29), ("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere"),
("Dilute Kohlfuchs", "aa CC dd ee GG PP spsp rere", 30), ("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere"),
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31), ("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere"),
// --- Schimmel / Fuchsschimmel (IDs 33-37) --- ("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere"),
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36), ("Gold-Hell", "AA CC DD EE GG pp spsp rere"),
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37), ("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere"),
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38), ("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere"),
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39), ("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere"),
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40), ("Kohlfuchsschimmel, hell", "aa CC DD efef GG PP spsp rere"),
// --- Hell variants (IDs 38-48) --- ("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere"),
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41), ("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere"),
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42), ("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere"),
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43), ("Topas dd", "AA CC dd EE GG pp spsp rere"),
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44), ("Blaufuchs dd", "aa CC dd ee gg pp spsp rere"),
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45), ("Marder", "aa cchmcchm DD EE GG PP spsp rere"),
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46), ("Siam", "aa cchmch DD EE GG PP spsp rere"),
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47), ("Zobel-Hell", "aa cchmch DD EE gg PP spsp rere"),
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48), ("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere"),
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49), ("CP-Silberagouti", "AA cchmcchm DD EE gg PP spsp rere"),
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50), ("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere"),
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51), ("CP-Polarfuchs", "AA cchmcchm DD ee gg PP spsp rere"),
// --- Dilute (dd) renamed variants (IDs 49-50) --- ("CP-Fuchs", "AA cchmcchm dd ee GG PP spsp rere"),
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52), ("CP-Fuchs-Hell", "AA cchmch dd ee GG PP spsp rere"),
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53), ("CP-Blaufuchs", "AA cchmcchm dd ee gg PP spsp rere"),
// --- Marder / Siam / CP- series (IDs 51-66) --- ("CP-Orangeschimmel", "AA cchmcchm DD efef GG PP spsp rere"),
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54), // GEN-3g: 5 new CP-*-Hell variants appended (IDs 62-66, no ID->Name drift)
("Siam", "aa cchmch DD EE GG PP spsp rere", 55), ("CP-Agouti-Hell", "AA cchmch DD EE GG PP spsp rere"),
("Zobel-Hell","aa cchmch DD EE gg PP spsp rere", 56), ("CP-Silberagouti-Hell", "AA cchmch DD EE gg PP spsp rere"),
("CP-Agouti", "AA cchmcchm DD EE GG PP spsp rere", 57), ("CP-Algierfuchs-Hell", "AA cchmch DD ee GG PP spsp rere"),
("CP-Silberagouti","AA cchmcchm DD EE gg PP spsp rere", 59), ("CP-Polarfuchs-Hell", "AA cchmch DD ee gg PP spsp rere"),
("CP-Algierfuchs", "AA cchmcchm DD ee GG PP spsp rere", 61), ("CP-Orangeschimmel-Hell","AA cchmch DD efef GG PP spsp rere"),
("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];
@@ -380,11 +362,11 @@ public class ApplicationContext : DbContext
{ {
rows[i] = new ColorVariety rows[i] = new ColorVariety
{ {
// Stable, deterministic GUIDs — ID = array_index + 1 (never reorder existing entries). // Stable, deterministic GUIDs so the HasData seed is migration-stable.
Id = new Guid($"00000000-0000-0000-0000-{(i + 1):D12}"), 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 = catalog[i].SortOrder, SortOrder = i,
}; };
} }
modelBuilder.Entity<ColorVariety>().HasData(rows); modelBuilder.Entity<ColorVariety>().HasData(rows);

View File

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

View File

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

View File

@@ -289,15 +289,10 @@ namespace GerbilManagerWebAPI.Import
litterParents[lid] = (f, m); litterParents[lid] = (f, m);
} }
// FIX-IMPORT-CYCLE: track deferred synth litter parent FKs (populated in PASS 2 below). // PASS 2: stage synthesized litters (parents already guarded above). DO NOT save them
// Synth litters are added with null FatherId/MotherId to break the Gerbil↔Litter cycle; // before the animals — the offspring AND the parent gerbils are created in the loop
// the actual FKs are applied AFTER SaveChanges once all gerbils are persisted. // below, so a single SaveChanges at the end lets EF order parents→litters→offspring
var synthLitterPendingParents = new Dictionary<Guid, (Guid? Father, Guid? Mother)>(); // (all FKs are nullable). Saving litters first is exactly what caused the FK fault.
// 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.
@@ -321,17 +316,13 @@ 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 = null, // deferred — applied after gerbils SaveChanges FatherId = sl.Father,
MotherId = null, // deferred — applied after gerbils SaveChanges MotherId = sl.Mother,
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})", Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",
}); });
} }
@@ -446,20 +437,6 @@ 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)
{ {

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -216,7 +216,7 @@ namespace GerbilManagerWebAPI.Migrations
{ {
Id = new Guid("00000000-0000-0000-0000-000000000001"), Id = new Guid("00000000-0000-0000-0000-000000000001"),
CanonicalGenotype = "AA chch DD EE GG pp spsp rere", CanonicalGenotype = "AA chch DD EE GG pp spsp rere",
Name = "REW", Name = "Pink Eyed White (PEW)",
SortOrder = 0 SortOrder = 0
}, },
new new
@@ -321,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 = "Dilute Gold", Name = "dd 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 = "Dilute Platin", Name = "dd Platin",
SortOrder = 16 SortOrder = 16
}, },
new new
@@ -412,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 = "Dilute Agouti", Name = "Agouti dd",
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 = "Dilute Silberagouti", Name = "Silberagouti dd",
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 = "Dilute Kohlfuchs", Name = "Kohlfuchs dd",
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 = "Dilute Anthrazit", Name = "Anthrazit dd",
SortOrder = 31 SortOrder = 31
}, },
new new
@@ -441,224 +441,224 @@ 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 = 36 SortOrder = 32
}, },
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 = 37 SortOrder = 33
}, },
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 = 38 SortOrder = 34
}, },
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 = 39 SortOrder = 35
}, },
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 = 40 SortOrder = 36
}, },
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 = 41 SortOrder = 37
}, },
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 = 42 SortOrder = 38
}, },
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 = 43 SortOrder = 39
}, },
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 = 44 SortOrder = 40
}, },
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 = 45 SortOrder = 41
}, },
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 = 46 SortOrder = 42
}, },
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 = 47 SortOrder = 43
}, },
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 = 48 SortOrder = 44
}, },
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 = 49 SortOrder = 45
}, },
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 = 50 SortOrder = 46
}, },
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 = 51 SortOrder = 47
}, },
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 = "Dilute Topas", Name = "Topas dd",
SortOrder = 52 SortOrder = 48
}, },
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 = "Dilute Blaufuchs", Name = "Blaufuchs dd",
SortOrder = 53 SortOrder = 49
}, },
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 = 54 SortOrder = 50
}, },
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 = 55 SortOrder = 51
}, },
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 = 56 SortOrder = 52
}, },
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 = 57 SortOrder = 53
}, },
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 = 59 SortOrder = 54
}, },
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 = 61 SortOrder = 55
}, },
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 = 63 SortOrder = 56
}, },
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 = 65 SortOrder = 57
}, },
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 = 66 SortOrder = 58
}, },
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 = 67 SortOrder = 59
}, },
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 SortOrder = 60
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000062"), Id = new Guid("00000000-0000-0000-0000-000000000062"),
CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere", CanonicalGenotype = "AA cchmch DD EE GG PP spsp rere",
Name = "CP-Agouti-Hell", Name = "CP-Agouti-Hell",
SortOrder = 58 SortOrder = 61
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000063"), Id = new Guid("00000000-0000-0000-0000-000000000063"),
CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere", CanonicalGenotype = "AA cchmch DD EE gg PP spsp rere",
Name = "CP-Silberagouti-Hell", Name = "CP-Silberagouti-Hell",
SortOrder = 60 SortOrder = 62
}, },
new new
{ {
Id = new Guid("00000000-0000-0000-0000-000000000064"), Id = new Guid("00000000-0000-0000-0000-000000000064"),
CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere", CanonicalGenotype = "AA cchmch DD ee GG PP spsp rere",
Name = "CP-Algierfuchs-Hell", Name = "CP-Algierfuchs-Hell",
SortOrder = 62 SortOrder = 63
}, },
new new
{ {
@@ -672,35 +672,7 @@ namespace GerbilManagerWebAPI.Migrations
Id = new Guid("00000000-0000-0000-0000-000000000066"), Id = new Guid("00000000-0000-0000-0000-000000000066"),
CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere", CanonicalGenotype = "AA cchmch DD efef GG PP spsp rere",
Name = "CP-Orangeschimmel-Hell", Name = "CP-Orangeschimmel-Hell",
SortOrder = 69 SortOrder = 65
},
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
}); });
}); });

View File

@@ -20,23 +20,6 @@ namespace GerbilManagerWebAPI.Names
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
}; };
// Known usage codes → (German description, isThematic).
// Thematic categories provide real source names (not etymology); unknown codes fall through as etym.
internal static readonly Dictionary<string, (string Description, bool Thematic)> UsageMap =
new(StringComparer.OrdinalIgnoreCase)
{
["norn"] = ("Nordische/Altnordische Etymologie", false),
["japa"] = ("Japanische Etymologie", false),
["mythg"] = ("Griechische Mythologie", false),
["ger"] = ("Germanische/Deutsche Etymologie", false),
["arb"] = ("Arabische Etymologie", false),
["disney"] = ("Disney-Charaktere", true),
["pokemon"] = ("Pokémon-Namen", true),
["encities"] = ("Namen englischer Städte", true),
["hrcities"] = ("Namen kroatischer Städte", true),
["usstates"] = ("Namen von US-Bundesstaaten", true),
};
public async Task<NameSuggestionResult> SuggestAsync( public async Task<NameSuggestionResult> SuggestAsync(
string? letter, string? gender, string? usages, int count, string? letter, string? gender, string? usages, int count,
CancellationToken ct = default) CancellationToken ct = default)
@@ -61,11 +44,7 @@ namespace GerbilManagerWebAPI.Names
"Du bist ein Helfer für Rennmaus-Züchter. " + "Du bist ein Helfer für Rennmaus-Züchter. " +
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " + "Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " + "KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " + "Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
"Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " +
"origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " +
"meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " +
"Erfinde KEINE Etymologie für thematische Kategorien.";
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count) internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
{ {
@@ -76,44 +55,10 @@ namespace GerbilManagerWebAPI.Names
if (!string.IsNullOrWhiteSpace(gender) && if (!string.IsNullOrWhiteSpace(gender) &&
!gender.Equals("any", StringComparison.OrdinalIgnoreCase)) !gender.Equals("any", StringComparison.OrdinalIgnoreCase))
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere"); sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
bool hasThematic = false;
if (!string.IsNullOrWhiteSpace(usages)) if (!string.IsNullOrWhiteSpace(usages))
{ sb.Append($", aus den Kulturkreisen: {usages}");
var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
var etymDescriptions = new List<string>(); sb.Append("(keine erfundenen oder zufälligen Namen). ");
var thematicDescriptions = new List<string>();
foreach (var code in codes)
{
if (UsageMap.TryGetValue(code, out var entry))
{
if (entry.Thematic) thematicDescriptions.Add(entry.Description);
else etymDescriptions.Add(entry.Description);
}
else
{
etymDescriptions.Add(code);
}
}
if (etymDescriptions.Count > 0)
sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}");
if (thematicDescriptions.Count > 0)
sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}");
hasThematic = thematicDescriptions.Count > 0;
}
sb.Append(". ");
if (hasThematic)
{
sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. ");
sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, ");
sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. ");
}
else
{
sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
sb.Append("(keine erfundenen oder zufälligen Namen). ");
}
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: "); sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]"); sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
return sb.ToString(); return sb.ToString();

View File

@@ -98,7 +98,6 @@ 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();

View File

@@ -2,78 +2,150 @@
> ## ⛔ 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
> („A3: hier das Passwort …", „PEW = REW: ja"). **Michael pflegt diese Datei** > („C1: ja", „A1: hier der Schlüssel …", „Ella: der erste Genotyp stimmt").
> und streicht jede Frage, sobald sie beantwortet ist. So steht hier immer nur > **Michael pflegt diese Datei** und streicht jede Frage, sobald sie beantwortet
> das, was **noch offen** ist. Nichts geht verloren. > ist. So steht hier immer nur das, was **noch offen** ist. Nichts geht verloren
> alles wartet geduldig, bis ihr antwortet.
_Stand: 2026-06-07. **Alle Genetik- und Konflikt-Fragen sind beantwortet ✅** (alle _Stand: 2026-06-06._
32 Konflikt-Tiere entschieden). Die KI-Funktionen sind **live und kostenlos**. Der
große finale Re-Import (mit allen 41 Stammbäumen + Wurfchronik) steht startbereit. Der Manager ist fertig und läuft. Die alten Daten sind importiert
Offen sind nur noch ein paar **technische Zugänge** und etwas **optionaler (**865 Würfe + 325 Tiere + 138 Fotos** sind drin, Stand Re-Import #2.5). Mehrere
Feinschliff** — siehe unten._ KI-Funktionen sind **fertig gebaut, schlafen aber**, bis ein Gemini-Schlüssel
hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
--- ---
## A. Zugänge & Technik (für Julian) ## A. Für Julian — Konten, Schlüssel, Technik
| # | Was gebraucht wird | Schaltet frei | | # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|---|---|---| |---|---|---|---|
| **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. | | ~~A1~~ | **ERLEDIGT (2026-06-07): Gemini-Key hinterlegt** (user-secrets, nie committed) + LIVE verifiziert — Verkaufstext-Endpunkt generiert echten Text. KI-Verkaufstexte + Charakterbogen-KI sind **live**. (Namensgenerator live sobald Backend gemerged; Posteingang braucht zusätzlich A3 Gmail; Web-Chat-Editor wenn gebaut.) | — | — |
| **A4** (umgestellt 2026-06-07) | **Self-Host statt Cloudflare:** Strato-Domain → DynDNS → deine IP (auto-aktualisiert) + **nginx-Reverse-Proxy auf TrueNAS** leitet auf die statische Seite. Cloudflare NICHT mehr nötig. **Offen:** welcher Hostname/Subdomain für die öffentliche Seite? Bau = WEB-2 (Dwight, self-host-Variante). | Öffentliche Webseite (Jimdo-Ersatz), self-hosted auf dem NAS. | | ~~A2~~ | ✅ **ERLEDIGT (2026-06-07): KOSTENLOS, kein Billing nötig.** `gemini-2.0-flash` hatte kein Free-Kontingent (429), aber **`gemini-flash-latest` funktioniert im Free-Tier** (direkt + live verifiziert). Modell überall darauf umgestellt. Julian bleibt gratis. (Falls je volle Privatsphäre gewünscht: Ollama-Option steht.) | — | — |
| ~~**A5**~~ ✅ | **TrueNAS beantwortet (2026-06-07):** (a) SCALE **25.10.2.1 Goldeye** → Custom App (Docker compose) ✓ · (c) **eigener Postgres-Container** (legen wir an, compose macht das) ✓ · (d) Daten/Backups unter **`/mnt/JailStorage/DockerVolumes/`** (neuer Ordner) ✓. **Rest-Detail:** Port **80** frei? (sonst `PORT` in .env ändern). → OPS-2 (Dwight) finalisiert compose/.env/ops.md auf diese Werte. | Produktiv-Betrieb auf dem NAS. | | 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) |
| **A5b2** | **2 Gitea-Repo-Secrets** anlegen (Repo `Gulum/GerbilManager`**Einstellungen → Actions → Secrets → Secret hinzufügen**): `REGISTRY_USER` = `gulum` · `REGISTRY_TOKEN` = Gitea-Zugriffstoken mit Scope **`write:package`** (erzeugen unter **Benutzer-Einstellungen → Anwendungen → Zugriffstoken verwalten**, Token wird nur einmal angezeigt → in das Secret kopieren). Danach Action erneut laufen lassen. Registry ist jetzt **`git.rismer.de`** (externes HTTPS) → keine `insecure-registry`-Daemon-Konfig nötig. | CI pusht fertige Docker-Images in die Registry. (Tests laufen grün; aktuell rot ist NUR der Login-Schritt: `secrets.REGISTRY_USER`/`REGISTRY_TOKEN` sind leer → „Username and password required".) | | A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~**AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
## B. Kleine Aktion (jederzeit) ## B. Für Julian — kleine Aktionen (jederzeit)
| # | Aktion | Warum | | # | Aktion | Warum |
|---|---|---| |---|---|---|
| ~~**B2**~~ ✅ | **Firewall-Regel erledigt** (2026-06-07) — Ports 5173/5179 inbound offen. Handy im WLAN kommt auf http://192.168.2.124:5173 (sobald App läuft). | — | | 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. |
## C. Genetik-Feinschliff (optional, blockiert nichts) | B3 | **2 Test-Einträge löschen:** „Testmaus E2E…" und „Wurf E2E…" | Übrig gebliebene Test-Einträge aus der Qualitätssicherung. |
| # | Frage | Betrifft |
|---|---|---|
| ~~**REW-1**~~ ✅ | **JA, PEW = REW (dasselbe)** → PEW-Eintrag mit REW zusammenführen (GEN-4c, Kevin). | Doppel-Eintrag im Farbkatalog. |
| ~~**REW-2**~~ ✅ | **A-unabhängig:** egal ob `AA`/`Aa` (groß) oder `aa` (klein) — wenn die REW-Bedingung erfüllt ist, ist es **immer REW**. Schon so implementiert (GEN-4, A/D/E/G-unabhängig). | REW-Erkennung. |
| **C7** | *(optional)* Was hat deiner Frau bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
## E. Charakterbogen-Eigenschaften — ✅ ERLEDIGT (CHARAKTERBOGEN-2)
**E1 ✅** — Deine Frau hat die Eigenschaften geliefert, Kelly hat sie eingebaut: **4 Kategorien** (Sozialverhalten · Eignung & Umgang · Hobbys & Eigenarten · Wesen & Temperament), die 15 alten Häkchen unverändert + **10 neue** (dominant, rangniedrig, sozialkompetent, für erfahrene Halter, Beobachtungstier, familiengeeignet, Schredder-Meister, Nestbauer/Architekt, territorial …).
**E2 ✅** — Ehrliche **Warnsignale** (z. B. *schwer vergesellschaftbar*, *territorial*) sind als eigene, optisch abgesetzte Gruppe drin — fließen in die KI-Verkaufstexte ein.
Weitere Eigenschaften lassen sich jederzeit **additiv** ergänzen (1-Zeilen-Änderung) — falls deiner Frau noch etwas einfällt, einfach sagen.
--- ---
## D7 · Neue Konflikt-Tiere aus den 41 Stammbäumen (bitte entscheiden) ## C. Für die Züchterin — Genetik-Schreibweise & Import-Prüfung
Durch die vielen neuen Stammbaum-Dateien sind **13 neue Konflikt-Tiere** aufgetaucht (**7 erledigt:** Kazumi/Filou/Sokrates/Osamu/Percy/Iwana/Eragon ✅ — **6 offen**) (gleicher Name+Datum, widersprüchliche Angaben in mehreren Diagrammen). Sie warten in Quarantäne — **nichts ist verloren**, sie laden automatisch nach, sobald du je Tier kurz sagst was stimmt. (Uw=G + „Vorhandensein gewinnt" sind schon angewendet; das hier ist der echte Rest.) 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).
_Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in denen das Tier vorkommt. **Mehrere Dateien = Ursache des Konflikts** (widersprüchliche Angaben in verschiedenen Diagrammen)._ | # | Frage | Blockiert |
**A) Nur Sterbedatum offen** (Gencode einig — bei Osamu/Filou/Sunny zusätzlich „taub" beibehalten):
| Tier | Sterbedatum — welches? | Quelldatei(en) |
|---|---|---| |---|---|---|
| Isa of Golden Lights (*24.12.2014) | 21.07.2018 ↔ 21.10.2018 | Stammbaum von **Ella**.xlsx · von **Kalea**.xlsx | | ~~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 |
| Jack II v.d. K.C. (*14.02.2016) | 06.10.2019 ↔ 20.10.2019 | Stammbaum von **Kalea**.xlsx · von **Rainny**.xlsx · von **Ren**.xlsx | | ~~C2~~ | ✅ **BEANTWORTET** (2026-06-06): Ja — **`-` = „Allel unbekannt"** (z. B. `D-` = ein D-Allel unbekannt). Wird beim Import als `?`-Platzhalter übernommen. | — erledigt |
| ~~Osamu v.d. K.C. (*10.12.2015)~~ | **18.12.2020** (erledigt) | — | | ~~C3~~ |**BEANTWORTET** (2026-06-06): Ja, automatisch zusammenführen — **aber nur wenn auch der Zuchtname gleich ist** (Name + Geburtsdatum + Zuchtname = dasselbe Tier). Wird in die Dedup-Regel eingebaut. (Hinweis: der Extraktor fand bisher 0 Fälle mit gleichem Name+Datum aber verschiedenem Zuchtnamen, also ändert sich an den bestehenden Zusammenführungen nichts — die Regel ist die Absicherung.) | — erledigt |
| ~~Filou v.d. K.C. (*24.11.2014)~~ ✅ | **31.08.2019** (erledigt) | — | | ~~C4~~ | ✅ **BEANTWORTET** (2026-06-06): Ja, **Wurfchronik Teil 2 existiert** — wird gerade überarbeitet, kommt später. Der Importer ist pro Datei wiederholbar (idempotent), also einfach die Datei schicken, sobald fertig → Michael importiert sie nach (keine Doppelungen). | ⏳ Datei folgt, wenn überarbeitet |
| Sunny von PZ Karl (*10.04.2014) | 30.04.2019 ↔ 05.05.2019 | Stammbaum von **Vance**.xlsx · von **Yurikas und Pintos Sohn**.xlsx | | ~~C5~~ | ✅ **BEANTWORTET** (2026-06-06): **Es gibt KEIN „Schwarzschimmel"** — das war ein Fehler in unserem Katalog. Die korrekten Schimmelarten: `efef`**Orangeschimmel** · `efef pp`**Rotaugenschimmel** · `efef gg`**Silberschimmel** · Kombis z. B. `c[chm]c[chm] efef`**CP-Orangeschimmel**. Michael korrigiert den Katalog (Schwarzschimmel raus, efef = Orangeschimmel). | — erledigt |
| C6 | **FAST ERLEDIGT** (Stand 06.06. nachmittags): von den ursprünglich 32 Konflikt-Tieren sind **27 geklärt + geladen** (deine D1D5-Antworten + Beibehalten-Regel + „genauer gewinnt"-Regel). **Offen sind nur noch die 5 Tiere in D6**: Hanami (Sterbedatum), Big Ben (PP↔Pp), Vance Jr. (Spsp↔spsp), Kazu (3 Loci), Skarlett (Sterbedatum). | nur diese 5 warten noch auf den Import |
| C7 | *(optional)* Was hat dir bei **Renner Pro** gefehlt? Lieblings-Auswertungen? | mögliche neue Funktionen |
| ~~C8~~ | ✅ **BEANTWORTET** (2026-06-06): **Himalaya gibt es** — Himalaya = **`A- c[h]c[h]`** (agouti), Hermelin = **`aa c[h]c[h]`** (nicht-agouti). Beide bleiben im Katalog; die Engine unterscheidet bereits korrekt nach A-/aa. — erledigt |
| ~~C9~~ | ✅ **BEANTWORTET** (2026-06-06): **„CP-Fuchs" ist ein Sammelbegriff** — bei diesen Tieren ist unklar, ob es CP-Polarfuchs, CP-Algierfuchs, CP-Kohlfuchs oder CP-Blaufuchs ist (Tiere sind schneeweiß mit schwarzen Augen; Verpaarungen haben die Gene nicht verraten). Bekannt ist nur: **„CP-Fuchs" = `c[chm]c[chm]`**, **„CP-Fuchs hell" = `c[chm]c[h]`**. **Generelle Regel: das Wort „hell" im Farbschlag-Namen bedeutet immer, dass ein `c[h]` im Gencode steckt** (also `c[chm]c[h]`); ohne „hell" = `c[chm]c[chm]`. Die „-Hell"-Vermutung war richtig ✓; Engine-Update beauftragt (GEN-3g): bei unbekannten Unterscheidungs-Loci bleibt der Sammelbegriff „CP-Fuchs" korrekt. — erledigt |
**B) Gencode-Konflikt** (+ ggf. Sterbedatum): ### Hinweis zu C5 — woher kam das falsche „Schwarzschimmel"? (wie gewünscht notiert)
| Tier | Konflikt — was stimmt? | Quelldatei(en) | „Schwarzschimmel" stammt aus **unserem ursprünglichen Farbkatalog** `gerbil-manager-web/src/genetics/catalog.ts` (Genotyp `efef`), den wir ganz am Anfang aus den deutschen Genetik-Quellen (de.wikibooks „Schwarze Augen", rennmauswelten, clan-of-topolino) aufgebaut hatten. Von dort kam es in die DB-Seed-Liste + Stammbaum-Farbchips. → Wird in GEN-3 korrigiert: Schwarzschimmel entfernt, `efef` = Orangeschimmel. *(Falls du der Quelle Bescheid geben willst: es ist die de.wikibooks-Farbgenetik-Seite.)*
---
## D. Die 32 Konflikt-Tiere (gleicher Name + Datum, aber widersprüchliche Angaben in mehreren Dateien)
> ✅ **STAND nach Re-Import #2.5 (06.06.2026):** Alle bisher beantworteten Konflikte sind **live geladen** (u. a. Victoria Welby: **„C" hat jetzt beide Eltern** ✔). Enya, Ella und Zac wurden inzwischen ebenfalls **automatisch geladen** („genauer gewinnt"-Regel: `CC` schlägt `C-`) → jetzt **325 Tiere** drin, 161 Eltern-Links nachgetragen. **Wirklich offen sind nur noch die 5 Tiere in D6 unten.**
Bitte je Tier kurz sagen, **welche Angabe stimmt**. Gruppiert nach Konflikt-Art.
Alle Details (sämtliche Genotyp-Varianten + Quelldateien): `tools/import/output/review-report.md`.
### D1 · Im Farbschlag-Feld steht versehentlich ein **Tiername** (Tippfehler) — welcher Farbschlag stimmt wirklich?
> ✅ **GEKLÄRT (Julian, 2026-06-06):** Ursache gefunden — **ab Spalte K** im Stammbaum stehen pro Tier nur Name / Datum / **Gencode** (KEINE Farbangabe). Der Importer hatte dort fälschlich die Nachbarzelle (z. B. den Namen des nächsten Tiers wie „Tennessee", oder eine Notiz wie „DD-Tumor") als Farbschlag gelesen. **Fix ist beauftragt:** in den tiefen Spalten wird kein Farbschlag mehr ausgelesen, die **Farbe wird aus dem Gencode berechnet**; in den frühen Spalten (Proband/Eltern) bleibt der echte Farbschlag erhalten (z. B. Chesnut = „Kohlfuchsschimmel"). **Chesnut und Tennessee sind getrennte Tiere** (bestätigt). → Nach dem Fix verschwindet D1 von selbst; **keine Aktion nötig.**
| Tier | im Farbschlag steht fälschlich | 📂 Stammbaum-Datei zum Nachschauen |
|---|---|---| |---|---|---|
| Milon v.d. K.C. (*27.11.2014) | A-Locus: **Aa****aa** | Stammbaum von **South Dakota**.xlsx · von **Tennessee**.xlsx | | ZoneFire (*07.12.2020) | „Kalea von den Kleinen Chaoten" | *Stammbaum von Akio Kids* |
| ~~Percy of little runners (*16.12.2017)~~ ✅ | P-Locus **Pp** (erledigt) | — | | Louis v.d. Kleinen Chaoten (*15.07.2017) | „Roswitha…" (+ Genotyp G/Uw, siehe D2) | *(Quelle siehe `review-report.md`)* |
| ~~Iwana of little runners (*02.10.2018)~~ ✅ | P-Locus **Pp** (erledigt) | — | | Bruno of Black Forest (*01.06.2022) | „Mystique of Black Forest" (evtl. Blau) | *Stammbaum von Alberto Kids / Fire Kids / Stella Kids* |
| ~~Sokrates v.d. K.C. (*14.12.2015)~~ ✅ | **D-** + Sterbedatum **20.05.2019** (erledigt) | — | | Little Runner's Big Ben (*03.02.2020) | „Daja of Little Rose" | *Stammbaum von Goldfuchs Sp (Pikachu) Kids* |
| ~~Eragon (Elieus, *18.05.2016)~~ ✅ | C **CC** (vollfarbig) + Name **„Elieus gen. Eragon"**. Korrekter Datensatz lädt bereits (CC); Colourpoint-Variante „Kleiner Warnowrenner …" bleibt als Dublette in Quarantäne — kein weiterer Schritt nötig. | — | | Vance Jr. v.d. Kleinen Chaoten (*10.04.2022) | „Velvet…" (evtl. Kohlfuchs, hell) | *Stammbaum von Fire Kids / Stella Kids* |
| Dakota of sweet little mouse (*30.01.2015) | A: **Aa**↔**aa** · P: **pp**↔**PP** · Sp: **Spsp**↔**spsp** | Stammbaum von **Jiminy of Black Forest**.xlsx · von **Yurikas und Pintos Sohn**.xlsx | | 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* |
| ~~Kazumi v.d. K.C. (*23.04.2013)~~ ✅ | **Aa Cc[chm] DD ee[f] GG PP Spsp** (erledigt) | — | | 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* |
| Max von Privat (*01.02.2013) | D: **D-**↔**DD** · P: **P-**↔**PP** · Sterbedatum (4 Varianten: 04.02.2016 / 04.03.2016 / 2014 / 30.12.2015) | Stammbaum von **Danako**.xlsx · von **Kalea**.xlsx · von **Vance**.xlsx · von **Wildfire und Vestras Kids**.xlsx · von **Yurikas und Pintos Sohn**.xlsx | | 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* |
*(Alle Gencode-Varianten + Quelldateien-Details: `tools/import/output/review-report.md`.)* ### ~~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)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Kazu v.d. K.C. (*23.04.2013)~~ | ✅ **ee[f] · GG · PP** — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Skarlett v.d. K.C. (*14.07.2013)~~ | ✅ **Sterbedatum 17.04.2016** — Julian 2026-06-07 → in conflict-decisions.json |
*(Enya, Ella und Zac fehlen hier bewusst: deren Abweichung ist nur „unbekannt ↔ genau angegeben" — löst der Importer automatisch mit der „genauer gewinnt"-Regel.)*
---
## E. Charakterbogen — Eigenschaften-Liste (für die KI-Verkaufstexte)
Aktuell eingebaute Häkchen-Eigenschaften (jede ist leicht änderbar). **Bitte
sagen, was ergänzt oder gestrichen werden soll:**
> zutraulich · handzahm · neugierig · aufgeschlossen · ruhig/ausgeglichen ·
> lebhaft/aktiv · verschmust · eigenständig · anfängergeeignet · futterfreudig ·
> buddelt gern · klettert gern · läuft gern im Laufrad · gut verträglich · schreckhaft
- **E1** — Eigenschaften hinzufügen/entfernen? (z. B. *dominant, rangniedrig, für erfahrene Halter, einzelgängerisch* …?)
- **E2** — Auch **ehrliche „negative" Eigenschaften** aufnehmen (z. B. *schreckhaft, beißt manchmal*) für ehrliche Inserate, oder nur Positives?
--- ---
@@ -81,25 +153,16 @@ _Quelldatei = die Stammbaum-Datei(en) in `C:\Users\gulum\dev\Sttammbäume\`, in
| Funktion | Status | Wartet auf | | Funktion | Status | Wartet auf |
|---|---|---| |---|---|---|
| KI-Verkaufstexte · Charakterbogen-KI · Namensgenerator | **LIVE & kostenlos** (Gemini Free-Tier) | — | | KI-Verkaufstexte (Abgeben) | gebaut, schläft | **A1** (+A2) |
| E-Mail-Posteingang (Anfragen) | gebaut | **A3** (Gmail-App-Passwort) | | Charakterbogen → KI-Text | gebaut, schläft | **A1** |
| Öffentliche Webseite + KI-Chat-Editor | gebaut; Hosting **self-hosted** (Strato/DynDNS + TrueNAS nginx) | Hostname-Wahl + WEB-2-Bau (Dwight) | | Webseiten-Chat-Editor (Gemini) | in Arbeit | **A1** + Webseiten-Phasen |
| NAS-Produktiv-Deployment + Docker-Push | vorbereitet, CI grün | **A5** + **A5b2** | | Öffentliche Webseite live | in Arbeit | **A4** (Domain + Cloudflare) |
| Handy-Zugriff im WLAN | ✅ **B2 Firewall erledigt** — testbar sobald App läuft | — | | E-Mail-Posteingang (Anfragen) | in Arbeit | **A3** (App-Passwort) + **A1/A2** für Entwürfe |
| Alle importierten Konflikt-Tiere | ✅ **alle 32 entschieden** | finaler Re-Import (startbereit, läuft demnächst) | | NAS-Deployment / Produktiv | fertig vorbereitet | **A5** |
| Restliche importierte Tiere (Konflikte) | nur noch 5 in Quarantäne (Re-Import #2.5 ✅, Enya/Ella/Zac geladen) | **D6** (5 Entscheidungen) |
| Handy-Zugriff im WLAN | App läuft | **B2** (Firewall) |
--- ---
## ✅ Erledigt (Archivnichts geht verloren) *Einfach abschnittsweise antwortenMichael arbeitet die Antworten ein, streicht
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).
- **C1C9 — Genetik-Schreibweise:** alles geklärt (Uw=G · „-"=Allel unbekannt · Dedup nach Name+Datum+Zucht · Schimmel-Arten · Himalaya/Hermelin · „CP-Fuchs"-Sammelbegriff + „hell"=`c[h]` · Dilute-Präfix · REW-Regel).
- **D1D6 — alle 32 Konflikt-Tiere:** entschieden und in `tools/import/conflict-decisions.json` hinterlegt (werden beim finalen Re-Import alle geladen).
*Details der erledigten Punkte stehen in der git-Historie und in `tools/import/conflict-decisions.json`.*
---
*Einfach abschnittsweise antworten — Michael arbeitet die Antworten ein und meldet, was dadurch live geht.*

View File

@@ -7,16 +7,16 @@ POSTGRES_PASSWORD=aendere_mich_bitte
# Externer Port fuer das Frontend (Standard: 80) # Externer Port fuer das Frontend (Standard: 80)
PORT=80 PORT=80
# Container Registry (git.rismer.de/gulum) # Gitea Container Registry (Standard: truenas:13000/gulum)
REGISTRY=git.rismer.de/gulum REGISTRY=truenas:13000/gulum
TAG=latest TAG=latest
# NAS-Dataset-Pfade (TrueNAS SCALE Goldeye: /mnt/JailStorage/DockerVolumes/...) # NAS-Dataset-Pfade (TrueNAS SCALE: /mnt/<Pool>/<Dataset>)
PGDATA_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata PGDATA_PATH=/mnt/SSD/gerbil/pgdata
PHOTOS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/photos PHOTOS_PATH=/mnt/SSD/gerbil/photos
BACKUPS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/backups BACKUPS_PATH=/mnt/SSD/gerbil/backups
# AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung) # AR-3: Data Protection Key-Ring (Gmail-App-Passwort-Verschlüsselung)
KEYS_PATH=/mnt/JailStorage/DockerVolumes/gerbilmanager/keys KEYS_PATH=/mnt/SSD/gerbil/keys
# Backup-Rotation: Anzahl Tage (Standard: 7) # Backup-Rotation: Anzahl Tage (Standard: 7)
BACKUP_KEEP_DAYS=7 BACKUP_KEEP_DAYS=7

View File

@@ -29,7 +29,7 @@ services:
# --- .NET API (GerbilManagerWebAPI) --- # --- .NET API (GerbilManagerWebAPI) ---
api: api:
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-api:${TAG:-latest}" image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-api:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: GerbilManagerWebAPI/Dockerfile dockerfile: GerbilManagerWebAPI/Dockerfile
@@ -63,7 +63,7 @@ services:
# --- nginx Frontend (React SPA + API-Proxy) --- # --- nginx Frontend (React SPA + API-Proxy) ---
frontend: frontend:
image: "${REGISTRY:-git.rismer.de/gulum}/gerbilmanager-frontend:${TAG:-latest}" image: "${REGISTRY:-truenas:13000/gulum}/gerbilmanager-frontend:${TAG:-latest}"
build: build:
context: ../.. context: ../..
dockerfile: gerbil-manager-web/Dockerfile dockerfile: gerbil-manager-web/Dockerfile
@@ -95,19 +95,19 @@ services:
volumes: volumes:
# NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren). # NAS-Datasets als Bind-Mounts (Pfade in .env konfigurieren).
# TrueNAS Goldeye: /mnt/JailStorage/DockerVolumes/gerbilmanager/<name> # TrueNAS: Dataset-Pfad z.B. /mnt/SSD/gerbil/pgdata
pgdata: pgdata:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PGDATA_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata}" device: "${PGDATA_PATH:-/mnt/gerbil/pgdata}"
photos: photos:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${PHOTOS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/photos}" device: "${PHOTOS_PATH:-/mnt/gerbil/photos}"
# AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung. # AR-3: Data Protection key ring — persistiert Gmail-App-Passwort-Verschlüsselung.
# Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos). # Muss ein persistentes NAS-Dataset sein (nicht dasselbe wie photos).
keys: keys:
@@ -115,10 +115,10 @@ volumes:
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${KEYS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/keys}" device: "${KEYS_PATH:-/mnt/gerbil/keys}"
backups: backups:
driver: local driver: local
driver_opts: driver_opts:
type: none type: none
o: bind o: bind
device: "${BACKUPS_PATH:-/mnt/JailStorage/DockerVolumes/gerbilmanager/backups}" device: "${BACKUPS_PATH:-/mnt/gerbil/backups}"

View File

@@ -1,4 +1,4 @@
# GerbilManager — Betriebsanleitung (TrueNAS SCALE Goldeye) # GerbilManager — Betriebsanleitung (TrueNAS)
> Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung). > Zielgruppe: Julian (Systemadministration) und Ehefrau (tägliche Nutzung).
> Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/) > Bookmark für die Ehefrau: **http://\<NAS-IP\>/** (z. B. http://truenas/)
@@ -9,12 +9,13 @@
1. [Übersicht & Architektur](#1-übersicht--architektur) 1. [Übersicht & Architektur](#1-übersicht--architektur)
2. [Voraussetzungen](#2-voraussetzungen) 2. [Voraussetzungen](#2-voraussetzungen)
3. [Erstinstallation auf TrueNAS Goldeye](#3-erstinstallation-auf-truenas-goldeye) 3. [Erstinstallation auf TrueNAS](#3-erstinstallation-auf-truenas)
4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren) 4. [App starten / stoppen / aktualisieren](#4-app-starten--stoppen--aktualisieren)
5. [Backup & Wiederherstellung](#5-backup--wiederherstellung) 5. [Backup & Wiederherstellung](#5-backup--wiederherstellung)
6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung) 6. [ZFS-Snapshot-Schichtung](#6-zfs-snapshot-schichtung)
7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions) 7. [CI/CD via Gitea Actions](#7-cicd-via-gitea-actions)
8. [Fehlerbehebung](#8-fehlerbehebung) 8. [Offene Fragen (bitte beantworten)](#8-offene-fragen)
9. [Fehlerbehebung](#9-fehlerbehebung)
--- ---
@@ -22,7 +23,7 @@
``` ```
Browser / Handy Browser / Handy
| HTTP :80 (oder PORT aus .env, z.B. 8080) | HTTP :80
v v
┌──────────────────┐ ┌──────────────────┐
│ frontend (nginx) │ statisches React-SPA + Reverse-Proxy │ frontend (nginx) │ statisches React-SPA + Reverse-Proxy
@@ -39,10 +40,9 @@ Browser / Handy
│ db (Postgres 17)│ │ backup (Sidecar) │ │ db (Postgres 17)│ │ backup (Sidecar) │
└──────────────────┘ │ pg_dump + tar + cron │ └──────────────────┘ │ pg_dump + tar + cron │
│ └──────────────────────┘ │ └──────────────────────┘
└─ pgdata-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata └─ pgdata-Volume (NAS-Dataset)
photos-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/photos photos-Volume (NAS-Dataset)
backups-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/backups backups-Volume (NAS-Dataset)
keys-Volume → /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
``` ```
**Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`). **Einziger veröffentlichter Port:** `80` (konfigurierbar via `PORT` in `.env`).
@@ -54,107 +54,68 @@ Alles andere läuft intern im Docker-Netz.
| Was | Details | | Was | Details |
|-----|---------| |-----|---------|
| TrueNAS SCALE | **25.10.2.1 „Goldeye"** (native Docker Custom Apps) | | TrueNAS SCALE | Electric Eel 24.10+ (native Docker Custom Apps) |
| Container Registry | `git.rismer.de/gulum` (externes HTTPS) | | Gitea | http://truenas:13000 — Repository `Gulum/GerbilManager` |
| Docker | bereits auf TrueNAS Goldeye vorhanden | | Docker | bereits auf TrueNAS vorhanden (Custom Apps nutzen es) |
| Verzeichnisse | 4 Ordner unter `/mnt/JailStorage/DockerVolumes/gerbilmanager/` anlegen (Schritt 3.1) | | Datasets | Drei ZFS-Datasets anlegen (siehe Schritt 3) |
--- ---
## 3. Erstinstallation auf TrueNAS Goldeye ## 3. Erstinstallation auf TrueNAS
### 3.1 Verzeichnisse anlegen und Berechtigungen setzen ### 3.1 ZFS-Datasets anlegen
Öffne eine Shell auf der NAS (TrueNAS → System → Shell oder SSH): In TrueNAS → **Datasets****Dataset hinzufügen** (je einmal wiederholen):
| Dataset-Name | Empfohlener Pfad | Verwendung |
|---|---|---|
| `gerbil/pgdata` | `/mnt/SSD/gerbil/pgdata` | Postgres-Datenbankdateien |
| `gerbil/photos` | `/mnt/SSD/gerbil/photos` | Hochgeladene Tierfotos |
| `gerbil/backups` | `/mnt/SSD/gerbil/backups` | Tägliche Backups |
> **Tipp:** Passe die Pool-Bezeichnung (`SSD`) an deinen tatsächlichen Pool an.
### 3.2 Repository klonen
```bash ```bash
# Vier Ordner anlegen # SSH in TrueNAS oder lokale Shell
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata git clone http://truenas:13000/Gulum/GerbilManager.git /opt/gerbilmanager
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/photos
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/backups
mkdir -p /mnt/JailStorage/DockerVolumes/gerbilmanager/keys
# Postgres-Container läuft als UID 999 (postgres) / GID 999 intern.
# pgdata muss von UID 999 beschreibbar sein; postgres erzwingt chmod 0700.
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
# photos, backups und keys werden von der API bzw. dem Sidecar beschrieben
# (laufen als root im Container) — keine weiteren ACL-Anpassungen nötig.
```
> **TrueNAS Dataset-ACL-Hinweis:** Falls `JailStorage` ein ZFS-Dataset mit NFSv4-ACLs ist,
> und `chown` meldet „Operation not permitted": setze in TrueNAS → Datasets →
> `JailStorage` → Berechtigungen → **ACL-Typ: POSIX** (oder nutze das UI-Formular
> „Eigentümer: 999, Gruppe: 999" für das `pgdata`-Unterverzeichnis).
### 3.2 Registry-Login auf der NAS
```bash
docker login git.rismer.de
# Benutzername und Token/Passwort eingeben (Gitea-Account oder Access Token mit read:packages)
```
Der Login wird unter `/root/.docker/config.json` gespeichert und bleibt nach Reboots erhalten.
### 3.3 Repository klonen
```bash
git clone https://git.rismer.de/gulum/GerbilManager.git /opt/gerbilmanager
cd /opt/gerbilmanager cd /opt/gerbilmanager
``` ```
### 3.4 Konfiguration anlegen ### 3.3 Konfiguration anlegen
```bash ```bash
cp deploy/truenas/.env.example deploy/truenas/.env cp deploy/truenas/.env.example deploy/truenas/.env
# Jetzt .env bearbeiten:
nano deploy/truenas/.env nano deploy/truenas/.env
``` ```
Mindestens setzen: Mindestens setzen:
- `POSTGRES_PASSWORD` — sicheres Passwort (mind. 20 Zeichen)
- `PGDATA_PATH`, `PHOTOS_PATH`, `BACKUPS_PATH` — tatsächliche Dataset-Pfade
| Variable | Wert | ### 3.4 Images bauen und App starten
|----------|------|
| `POSTGRES_PASSWORD` | Sicheres Passwort (mind. 20 Zeichen, keine `"`) |
| `AI__BaseUrl` | Gemini: `https://generativelanguage.googleapis.com/v1beta/openai` |
| `AI__ApiKey` | Dein Gemini API-Key |
| `AI__Model` | `gemini-2.0-flash` (oder `gemini-flash-latest`) |
| `PORT` | `80` — falls Port 80 auf der NAS bereits belegt ist: **auf `8080` ändern** |
Die Pfad-Variablen (`PGDATA_PATH`, `PHOTOS_PATH`, etc.) sind bereits auf die Goldeye-Standardpfade
vorbelegt und müssen nur geändert werden, wenn du einen anderen Pool nutzt.
### 3.5 Images ziehen und App starten
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml pull docker compose -f deploy/truenas/compose.yaml build
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen). Erster Start dauert ca. 23 Minuten (Postgres-Init + EF-Migrationen).
> **TrueNAS Goldeye Custom App (Alternative):** ### 3.5 Prüfen
> Statt der Shell kann die App auch über TrueNAS → Apps → „Custom App installieren" →
> „Install via YAML" deployt werden: compose-Inhalt einfügen, Volumes als Host-Pfade
> konfigurieren. Die Shell-Methode ist einfacher und gibt mehr Kontrolle.
### 3.6 Verifikation
```bash ```bash
# Alle 4 Container laufen? # Alle Container laufen?
docker compose -f deploy/truenas/compose.yaml ps docker compose -f deploy/truenas/compose.yaml ps
# API-Healthcheck (erwartet: {"status":"Healthy"}) # API-Healthcheck
curl -s http://localhost/api/health curl http://localhost/api/health
# Tier-Gesamtanzahl prüfen (erwartet > 0 nach Import) # Webapp im Browser
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*' http://<NAS-IP>/
# API-Doku (Scalar) im Browser
http://<NAS-IP>/scalar
# Foto-Upload: in der Webapp ein Tier öffnen → Foto hochladen → Foto erscheint
``` ```
--- ---
@@ -164,7 +125,6 @@ http://<NAS-IP>/scalar
### Starten ### Starten
```bash ```bash
cd /opt/gerbilmanager
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
@@ -174,17 +134,26 @@ docker compose -f deploy/truenas/compose.yaml up -d
docker compose -f deploy/truenas/compose.yaml down docker compose -f deploy/truenas/compose.yaml down
``` ```
### Aktualisieren (nach CI-Push auf main) ### Aktualisieren (nach `git push` auf main)
```bash ```bash
cd /opt/gerbilmanager cd /opt/gerbilmanager
git pull git pull
docker compose -f deploy/truenas/compose.yaml pull docker compose -f deploy/truenas/compose.yaml build
docker compose -f deploy/truenas/compose.yaml up -d docker compose -f deploy/truenas/compose.yaml up -d
``` ```
> EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig. > EF-Migrationen laufen automatisch beim API-Start — kein manueller Schritt nötig.
### Mit Gitea CI (wenn Actions aktiviert)
Push auf `main` triggert automatisch Build → Test → Image-Push.
Danach auf der NAS:
```bash
docker compose -f deploy/truenas/compose.yaml pull
docker compose -f deploy/truenas/compose.yaml up -d
```
--- ---
## 5. Backup & Wiederherstellung ## 5. Backup & Wiederherstellung
@@ -196,12 +165,12 @@ Der `backup`-Sidecar-Container läuft dauerhaft und sichert täglich um **03:00
- Komprimiertes Foto-Archiv als `.tar.gz` - Komprimiertes Foto-Archiv als `.tar.gz`
- Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht - Rotation: Backups älter als `BACKUP_KEEP_DAYS` (Standard: 7) werden gelöscht
Backups liegen unter: `/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/YYYY-MM-DD_HH-MM/` Backups liegen unter: `${BACKUPS_PATH}/YYYY-MM-DD_HH-MM/`
``` ```
/mnt/JailStorage/DockerVolumes/gerbilmanager/backups/ /mnt/SSD/gerbil/backups/
2026-06-06_03-00/ 2026-06-06_03-00/
gerbilmanager_2026-06-06_03-00.sql (Datenbank-Dump, Klartext SQL) gerbilmanager_2026-06-06_03-00.sql (Datenbank)
photos_2026-06-06_03-00.tar.gz (Fotos) photos_2026-06-06_03-00.tar.gz (Fotos)
backup.log (Protokoll) backup.log (Protokoll)
``` ```
@@ -215,68 +184,52 @@ docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backu
### Backup-Log prüfen ### Backup-Log prüfen
```bash ```bash
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log tail -50 /mnt/SSD/gerbil/backups/backup.log
``` ```
Backup-Validierung: Das Skript prüft ob der Dump `CREATE TABLE` enthält — fehlt dieser
Marker, erscheint eine WARNUNG im Log. Größe 0 KB bedeutet Fehlschlag.
### Wiederherstellung — Runbook ### Wiederherstellung — Runbook
> **WARNUNG:** Alle aktuellen Datenbankdaten und Fotos werden überschrieben! > **WARNUNG:** Alle aktuellen Daten werden überschrieben!
**Schritt 1:** API und Frontend stoppen (DB und backup-Sidecar laufen weiter) **Schritt 1:** App stoppen (optional, aber empfohlen)
```bash ```bash
docker compose -f deploy/truenas/compose.yaml stop api frontend docker compose -f deploy/truenas/compose.yaml stop api frontend
``` ```
**Schritt 2:** Restore ausführen **Schritt 2:** Restore ausführen
```bash ```bash
# Neuestes Backup automatisch wählen und bestätigen: # Neuestes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec -T backup \ docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh latest -f /bin/sh /scripts/restore.sh
# Bestimmtes Backup (Datum aus Verzeichnisname): # Bestimmtes Backup wiederherstellen:
docker compose -f deploy/truenas/compose.yaml exec -T backup \ docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/restore.sh 2026-06-06_03-00 -f /bin/sh /scripts/restore.sh 2026-06-05_03-00
``` ```
Das Skript:
1. Trennt alle offenen DB-Verbindungen
2. Spielt den SQL-Dump mit `psql -h db -U postgres -d gerbilmanager < dump.sql` ein
3. Entpackt das Foto-Archiv nach `/data/photos`
**Schritt 3:** API neu starten **Schritt 3:** API neu starten
```bash ```bash
docker compose -f deploy/truenas/compose.yaml start api frontend docker compose -f deploy/truenas/compose.yaml start api frontend
``` ```
**Schritt 4 — Verifikation (Pflicht nach erstem Restore-Drill):** **Schritt 4:** Prüfen
```bash ```bash
# Tier-Anzahl prüfen curl http://localhost/api/color-varieties | grep -c '"id"'
curl -s "http://localhost/api/gerbils?pageSize=1" | grep -o '"totalCount":[0-9]*' # Erwarteter Wert: 73
# ColorVariety-Anzahl (Stammdaten, erwartet: >= 60)
curl -s http://localhost/api/color-varieties | python3 -c "import sys,json; print(len(json.load(sys.stdin)))"
# Foto stichprobenartig prüfen
ls /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/ | head -5
``` ```
### Restore-Nachweis (Round-Trip-Test, lokal 2026-06-06) ### Restore-Nachweis (Round-Trip-Test)
Protokoll vom getesteten Restore auf lokalem Aspire-Postgres: Protokoll vom Test auf lokalem Aspire-Postgres (Vorgänger-Instanz, 2026-06-06 07:09):
``` ```
73 ColorVarieties vorhanden 73 ColorVarieties vorhanden
→ DELETE 12 Zeilen → 61 verbleibend → DELETE 12 Zeilen → 61 verbleibend
→ psql < dump.sql eingespielt → pg_restore eingespielt
→ 73 ColorVarieties bestätigt → 73 ColorVarieties bestätigt
Exit-Code: 0 Exit-Code: 0
``` ```
Die Container-Restore-Skripte nutzen dieselbe `psql < dump.sql` Logik.
**Erster TrueNAS-Restore-Drill:** nach Erstinstallation bitte ausführen und Tier-Anzahl **Erster echter Test auf TrueNAS:** nach Erstinstallation bitte ausführen und das Ergebnis notieren.
notieren — beweist dass Backup + Restore auf dem NAS korrekt funktionieren.
--- ---
@@ -287,15 +240,15 @@ Sie schützen vor versehentlichem Datenverlust auf Dataset-Ebene.
### Empfohlene Snapshot-Konfiguration ### Empfohlene Snapshot-Konfiguration
In TrueNAS → **Datasets**`JailStorage/DockerVolumes/gerbilmanager`**Snapshots****Regelmäßige Snapshots**: In TrueNAS → **Datasets**Dataset auswählen**Snapshots****Regelmäßige Snapshots**:
| Unterordner | Häufigkeit | Aufbewahrung | | Dataset | Häufigkeit | Aufbewahrung |
|-------------|-----------|--------------| |---------|-----------|--------------|
| `.../photos` | Stündlich | 24 Stunden | | `gerbil/photos` | Stündlich | 24 Stunden |
| `.../photos` | Täglich | 30 Tage | | `gerbil/photos` | Täglich | 30 Tage |
| `.../pgdata` | Stündlich | 24 Stunden | | `gerbil/pgdata` | Stündlich | 24 Stunden |
| `.../pgdata` | Täglich | 30 Tage | | `gerbil/pgdata` | Täglich | 30 Tage |
| `.../backups` | Täglich | 90 Tage | | `gerbil/backups` | Täglich | 90 Tage |
> **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent, > **Hinweis:** `pgdata` enthält Live-Postgres-Dateien. ZFS-Snapshots davon sind crash-konsistent,
> aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden, > aber **nicht** application-konsistent — für einen sauberen DB-Restore immer den `pg_dump` verwenden,
@@ -304,93 +257,94 @@ In TrueNAS → **Datasets** → `JailStorage/DockerVolumes/gerbilmanager` → **
### Snapshot manuell erstellen (z. B. vor Update) ### Snapshot manuell erstellen (z. B. vor Update)
```bash ```bash
# Pool-/Dataset-Name anpassen falls nötig # TrueNAS CLI
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/photos@vor-update-$(date +%Y%m%d) zfs snapshot SSD/gerbil/photos@vor-update-$(date +%Y%m%d)
zfs snapshot JailStorage/DockerVolumes/gerbilmanager/backups@vor-update-$(date +%Y%m%d) zfs snapshot SSD/gerbil/backups@vor-update-$(date +%Y%m%d)
``` ```
### Aus ZFS-Snapshot wiederherstellen (Fotos) ### Aus ZFS-Snapshot wiederherstellen (Fotos)
```bash ```bash
# Snapshots auflisten # Snapshot auflisten
zfs list -t snapshot JailStorage/DockerVolumes/gerbilmanager/photos zfs list -t snapshot SSD/gerbil/photos
# Einzelne Datei aus Snapshot kopieren # Datei aus Snapshot kopieren
cp /mnt/JailStorage/DockerVolumes/gerbilmanager/photos/.zfs/snapshot/<NAME>/datei.jpg \ cp /mnt/SSD/gerbil/photos/.zfs/snapshot/<NAME>/datei.jpg /mnt/SSD/gerbil/photos/
/mnt/JailStorage/DockerVolumes/gerbilmanager/photos/
``` ```
--- ---
## 7. CI/CD via Gitea Actions ## 7. CI/CD via Gitea Actions
CI pusht Images nach Erfolg zu `git.rismer.de/gulum/gerbilmanager-api` und Der Workflow `.gitea/workflows/ci.yml` ist als **Entwurf vorhanden, aber inaktiv**.
`git.rismer.de/gulum/gerbilmanager-frontend`.
### Registry-Secrets in Gitea ### Aktivierung
Gitea → Repository → Einstellungen → Secrets: 1. **Gitea Actions aktivieren:**
Gitea → Repository `GerbilManager` → Einstellungen → Actions → "Actions aktivieren"
| Secret | Wert | 2. **Gitea Actions Runner installieren** (auf TrueNAS oder einem separaten Gerät):
|--------|------| ```bash
| `REGISTRY_USER` | Gitea-Benutzername | # Gitea Runner Container (einfachste Variante für TrueNAS)
| `REGISTRY_TOKEN` | Gitea Access Token mit `package:write` | docker run -d --name gitea-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/gitea-runner:/data \
-e GITEA_INSTANCE_URL=http://truenas:13000 \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN> \
gitea/act_runner:latest
```
Token: Gitea → Admin → Actions → Runner → "Runner hinzufügen"
### Update nach CI-Push 3. **Registry-Secrets konfigurieren:**
Gitea → Repository → Einstellungen → Secrets:
- `REGISTRY_USER` — dein Gitea-Benutzername
- `REGISTRY_TOKEN` — Gitea Access Token mit `package:write`-Berechtigung
```bash ### Workflow nach Aktivierung
# Auf der NAS nach erfolgreichem CI-Lauf:
cd /opt/gerbilmanager ```
git pull git push origin main
docker compose -f deploy/truenas/compose.yaml pull → Gitea Actions: dotnet test + npm test + npm run build
docker compose -f deploy/truenas/compose.yaml up -d → Bei Erfolg: docker build + push zu truenas:13000/gulum/
→ Auf NAS: docker compose pull + up -d
``` ```
--- ---
## 8. Fehlerbehebung ## 8. Offene Fragen
Bitte beantworte diese Fragen, damit das Setup fertiggestellt werden kann:
| # | Frage | Auswirkung |
|---|-------|-----------|
| 1 | **TrueNAS SCALE Version?** Electric Eel 24.10 hat native Docker Custom Apps. Ältere Versionen nutzen Kubernetes. | Bestimmt ob `docker compose` direkt läuft |
| 2 | **Gitea Actions verfügbar/aktivierbar?** | CI/CD-Workflow aktiv oder nur manuell deployen |
| 3 | **Eigener Postgres-Container (empfohlen) oder vorhandene NAS-Postgres-App?** | Isolation vs. geteilte Instanz |
| 4 | **Genaue Dataset-Pfade?** Poolname und Pfad-Präfix | `.env`-Konfiguration |
| 5 | **Port-Wahl?** Standard 80 — frei auf der NAS? | `PORT`-Wert in `.env` |
---
## 9. Fehlerbehebung
### App startet nicht ### App startet nicht
```bash ```bash
# Logs aller Container
docker compose -f deploy/truenas/compose.yaml logs docker compose -f deploy/truenas/compose.yaml logs
# Logs eines bestimmten Containers
docker compose -f deploy/truenas/compose.yaml logs api docker compose -f deploy/truenas/compose.yaml logs api
docker compose -f deploy/truenas/compose.yaml logs db docker compose -f deploy/truenas/compose.yaml logs db
``` ```
### Port 80 belegt
Falls Port 80 vom TrueNAS-System selbst genutzt wird:
```bash
# In deploy/truenas/.env:
PORT=8080
# Dann neu starten:
docker compose -f deploy/truenas/compose.yaml up -d
```
### Postgres startet nicht (Permission denied auf pgdata)
```bash
# UID 999 muss Eigentümer des pgdata-Verzeichnisses sein:
chown -R 999:999 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
chmod 700 /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata
docker compose -f deploy/truenas/compose.yaml restart db
```
### Registry-Pull schlägt fehl
```bash
# Neu einloggen:
docker login git.rismer.de
# Dann pull wiederholen:
docker compose -f deploy/truenas/compose.yaml pull
```
### Datenbank nicht erreichbar ### Datenbank nicht erreichbar
```bash ```bash
# DB-Container läuft?
docker compose -f deploy/truenas/compose.yaml ps db docker compose -f deploy/truenas/compose.yaml ps db
# Verbindung testen
docker compose -f deploy/truenas/compose.yaml exec db \ docker compose -f deploy/truenas/compose.yaml exec db \
psql -U postgres -d gerbilmanager -c "\dt" psql -U postgres -d gerbilmanager -c "\dt"
``` ```
@@ -398,16 +352,27 @@ docker compose -f deploy/truenas/compose.yaml exec db \
### Backup-Fehler ### Backup-Fehler
```bash ```bash
tail -50 /mnt/JailStorage/DockerVolumes/gerbilmanager/backups/backup.log # Backup-Log prüfen
docker compose -f deploy/truenas/compose.yaml exec backup /bin/sh /scripts/backup.sh cat /mnt/SSD/gerbil/backups/backup.log | tail -30
# Backup manuell starten (mit Fehlerausgabe)
docker compose -f deploy/truenas/compose.yaml exec backup \
/bin/sh /scripts/backup.sh
``` ```
### Fotos werden nicht angezeigt ### Fotos werden nicht angezeigt
Prüfe ob das `photos`-Volume korrekt gemounted ist:
```bash ```bash
docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos docker compose -f deploy/truenas/compose.yaml exec api ls /data/photos
``` ```
### Container-Status zurücksetzen (Neustart)
```bash
docker compose -f deploy/truenas/compose.yaml restart api
```
### Kompletter Neustart (Daten bleiben erhalten) ### Kompletter Neustart (Daten bleiben erhalten)
```bash ```bash

View File

@@ -40,25 +40,6 @@ 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

View File

@@ -54,23 +54,6 @@ test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ pag
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible() 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 }) => { test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await page.goto('/rennmaeuse/nameless-stub/stammbaum') await page.goto('/rennmaeuse/nameless-stub/stammbaum')
@@ -79,34 +62,3 @@ test('Namenloser Ahne zeigt Platzhalter in der Stammbaum-Karte (UI-POLISH-2)', a
// Karte selbst zeigt '(ohne Namen)' statt leer // Karte selbst zeigt '(ohne Namen)' statt leer
await expect(page.locator('.pedigree-card__nametext')).toHaveText(de.pages.gerbils.nameless) 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 })
})

View File

@@ -1,49 +0,0 @@
/**
* 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)`)

View File

@@ -10,8 +10,7 @@
"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",

View File

@@ -62,7 +62,12 @@ describe('NAMEGEN_USAGES', () => {
expect(codes).toContain('mythg') expect(codes).toContain('mythg')
expect(codes).toContain('ger') expect(codes).toContain('ger')
expect(codes).toContain('arb') expect(codes).toContain('arb')
expect(codes).toHaveLength(5) expect(codes).toContain('disney')
expect(codes).toContain('pokemon')
expect(codes).toContain('encities')
expect(codes).toContain('hrcities')
expect(codes).toContain('usstates')
expect(codes).toHaveLength(10)
}) })
it('every usage has a non-empty label', () => { it('every usage has a non-empty label', () => {

View File

@@ -13,6 +13,11 @@ export const NAMEGEN_USAGES = [
{ code: 'mythg', label: 'Griech. Mythologie' }, { code: 'mythg', label: 'Griech. Mythologie' },
{ code: 'ger', label: 'Deutsch' }, { code: 'ger', label: 'Deutsch' },
{ code: 'arb', label: 'Arabisch' }, { code: 'arb', label: 'Arabisch' },
{ code: 'disney', label: 'Disney' },
{ code: 'pokemon', label: 'Pokémon' },
{ code: 'encities', label: 'Englische Städte' },
{ code: 'hrcities', label: 'Kroatische Städte' },
{ code: 'usstates', label: 'US-Bundesstaaten' },
] as const ] as const
export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code'] export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code']

View File

@@ -1,5 +1,5 @@
import { de } from '../strings/de' import { de } from '../strings/de'
import { ALL_TRAITS, TRAIT_CATEGORIES } from '../format/traits' import { ALL_TRAITS } from '../format/traits'
import './charakterbogen.css' import './charakterbogen.css'
export interface CharakterbogenProps { export interface CharakterbogenProps {
@@ -10,6 +10,12 @@ 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,
@@ -23,32 +29,26 @@ 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 vocabulary (category) order for stable output. // Preserve the vocabulary order for stable output.
onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key)) onTraitsChange(ALL_TRAITS.filter((tr) => next.has(tr.key)).map((tr) => tr.key))
} }
return ( return (
<div className="charakterbogen"> <div className="charakterbogen">
{TRAIT_CATEGORIES.map((cat) => ( <ul className="trait-grid">
<section key={cat.category} className="trait-category"> {ALL_TRAITS.map((tr) => (
<h4 className="trait-category__heading">{cat.category}</h4> <li key={tr.key}>
<ul className="trait-grid"> <label className="trait-chip">
{cat.traits.map((tr) => ( <input
<li key={tr.key}> type="checkbox"
<label className={`trait-chip${tr.warn ? ' trait-chip--warn' : ''}`}> checked={selected.has(tr.key)}
<input onChange={() => toggle(tr.key)}
type="checkbox" />
checked={selected.has(tr.key)} <span>{tr.label}</span>
onChange={() => toggle(tr.key)} </label>
/> </li>
<span>{tr.label}</span> ))}
{tr.warn && <span className="trait-warn-badge">{t.warnLabel}</span>} </ul>
</label>
</li>
))}
</ul>
</section>
))}
<label className="field"> <label className="field">
<span>{t.noteLabel}</span> <span>{t.noteLabel}</span>
<textarea <textarea

View File

@@ -1,21 +1,8 @@
/* CHARAKTERBOGEN-2 — categorised trait grid with warn-signal styling (mobile-first). */ /* FEAT-14 Charakterbogen — trait checkbox grid (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.5rem; margin: 0 0 0.75rem;
padding: 0; padding: 0;
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -52,16 +39,3 @@
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;
}

View File

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

View File

@@ -1,23 +1,9 @@
/** CHARAKTERBOGEN-2: map character trait KEYS (stored) <-> German LABELS (de.character.traitCategories). */ /** FEAT-14: map character trait KEYS (stored) <-> German LABELS (de.character.traits). */
import { de } from '../strings/de' import { de } from '../strings/de'
export interface TraitEntry { export const ALL_TRAITS = de.character.traits
key: string
label: string
warn?: true
}
export interface TraitCategory { const LABEL_BY_KEY = new Map<string, string>(de.character.traits.map((t) => [t.key, t.label]))
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
@@ -27,7 +13,3 @@ 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)
}

View File

@@ -1,24 +0,0 @@
/**
* 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))
})
})

View File

@@ -46,8 +46,8 @@ describe('Fraction', () => {
describe('Genotype serialization', () => { describe('Genotype serialization', () => {
it('round-trips display string <-> structured form', () => { it('round-trips display string <-> structured form', () => {
const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere') const g = fromDisplayString('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp') expect(toDisplayString(g)).toBe('Aa CC Dd EE GG Pp Spsp rere')
expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp') expect(toDisplayString(fromJSON(toJSON(g)))).toBe('Aa CC Dd EE GG Pp Spsp rere')
}) })
it('parses multi-char C-series alleles via maximal munch', () => { it('parses multi-char C-series alleles via maximal munch', () => {
@@ -72,8 +72,8 @@ describe('Genotype serialization', () => {
expect(g.P).toEqual(['P', 'p']) expect(g.P).toEqual(['P', 'p'])
}) })
it('wild type is AA CC DD EE GG PP spsp (rere omitted)', () => { it('wild type is AA CC DD EE GG PP spsp rere', () => {
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp') expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
}) })
}) })
@@ -102,7 +102,7 @@ describe('Worked example from research report', () => {
expect(result.offspring).toHaveLength(1) expect(result.offspring).toHaveLength(1)
const only = result.offspring[0] const only = result.offspring[0]
expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp') expect(only.genotype).toBe('Aa CC DD EE GG Pp spsp rere')
expect(only.probability.text).toBe('1') expect(only.probability.text).toBe('1')
expect(result.warnings).toHaveLength(0) expect(result.warnings).toHaveLength(0)
}) })
@@ -185,11 +185,15 @@ describe('Farbschlag catalog', () => {
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
// The first 17 frozen ColorVariety keys — their representative genotype // The first 18 are the frozen ColorVariety keys — their representative
// MUST resolve back to their own name, never a later variety. // genotype MUST resolve back to their own name, never a later variety.
// GEN-4c: 'REW' (formerly 'Pink Eyed White (PEW)') round-trips correctly: // GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
// chch+pp → REW engine check → 'REW' = entry.name. // 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)
} }
}) })
@@ -220,12 +224,11 @@ describe('Farbschlag catalog', () => {
it('CATALOG seed view mirrors the ColorVariety table shape', () => { it('CATALOG seed view mirrors the ColorVariety table shape', () => {
expect(CATALOG).toHaveLength(CATALOG_SIZE) expect(CATALOG).toHaveLength(CATALOG_SIZE)
expect(CATALOG[0]).toMatchObject({ name: 'REW', sortOrder: 0 }) expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
// Every row has a non-empty canonical genotype display string and unique name. // Every row has a non-empty canonical genotype display string and unique name.
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens. // GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
// GEN-4d: rere omitted from display -> 7 tokens (no Rex, no Sls), 8 (Rex or Sls), 9 (both).
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length) expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){6,8}$/.test(c.canonicalGenotype))).toBe(true) expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
}) })
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => { it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
@@ -286,7 +289,7 @@ describe('GEN-3a: Uw=G alias', () => {
it('always RENDERS G, never Uw (breeder preference)', () => { it('always RENDERS G, never Uw (breeder preference)', () => {
// Uw/uw is an input/import alias only; output must echo G/g. // Uw/uw is an input/import alias only; output must echo G/g.
expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD EE Uwuw PP spsp rere'))).toBe(
'AA CC DD EE Gg PP spsp', 'AA CC DD EE Gg PP spsp rere',
) )
expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw') expect(toDisplayString(fromDisplayString('AA CC DD EE uwuw PP spsp rere'))).not.toContain('uw')
}) })
@@ -298,16 +301,10 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl']) expect(fromDisplayString('AA CC DD EE GG PP spsp rere WP').Sls).toEqual(['Sl', 'sl'])
}) })
it('toDisplayString omits wild-type Sls and Re, shows Slsl/Rere when non-wildtype', () => { it('toDisplayString omits wild-type Sls but shows Slsl', () => {
// GEN-4d: Re (rere) omitted at wildtype, like Sls. expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp rere')
expect(toDisplayString(wildType())).toBe('AA CC DD EE GG PP spsp')
// Rex het → Rere shown
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp Rere'))).toBe(
'AA CC DD EE GG PP spsp Rere',
)
// WP → Slsl shown, rere still omitted
expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD EE GG PP spsp rere WP'))).toBe(
'AA CC DD EE GG PP spsp Slsl', 'AA CC DD EE GG PP spsp rere Slsl',
) )
}) })
@@ -337,7 +334,7 @@ describe('GEN-3a: second spotting locus Sls (WP)', () => {
describe('GEN-3a: flag/metadata tokens tolerated', () => { describe('GEN-3a: flag/metadata tokens tolerated', () => {
it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => { it('dea/taub/Dea/DP/WFNZ/RV/GV do not break parsing (stripped)', () => {
const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV') const g = fromDisplayString('AA CC DD EE GG PP spsp rere dea WFNZ DP RV GV')
expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp') expect(toDisplayString(g)).toBe('AA CC DD EE GG PP spsp rere')
}) })
it('extractGenotypeFlags reads deafness + tags', () => { it('extractGenotypeFlags reads deafness + tags', () => {
@@ -370,21 +367,19 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
it("accepts '-' input, stores '?', displays '-'", () => { it("accepts '-' input, stores '?', displays '-'", () => {
const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere') const g = fromDisplayString('Aa C- DD EE GG Pp spsp rere')
expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?' expect(g.C).toEqual(['C', '?']) // stored internal contract stays '?'
expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp') // displayed as '-' expect(toDisplayString(g)).toBe('Aa C- DD EE GG Pp spsp rere') // displayed as '-'
}) })
it("'?' and '-' inputs are equivalent", () => { it("'?' and '-' inputs are equivalent", () => {
expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('Aa C? DD EE GG Pp spsp rere'))).toBe(
'Aa C- DD EE GG Pp spsp', 'Aa C- DD EE GG Pp spsp rere',
) )
}) })
}) })
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => { describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => { it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
// 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(
'Kohlfuchsschimmel', 'Fuchsschimmel',
) )
}) })
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,10 +394,10 @@ describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', ()
describe('GEN-3d: dominance tiebreak for unknown loci', () => { describe('GEN-3d: dominance tiebreak for unknown loci', () => {
it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => { it('unknown-C reads as full-colour, NOT a c^h/c^chm white', () => {
// 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never REW/Hermelin/Himalaya. // 'aa C- DD EE GG PP' -> Schwarz (dominant C reading), never PEW/Hermelin/Himalaya.
const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere')) const name = genotypeToFarbschlag(fromDisplayString('aa C- DD EE GG PP spsp rere'))
expect(name).toBe('Schwarz') expect(name).toBe('Schwarz')
expect(['REW', 'Hermelin', 'Himalaya']).not.toContain(name) expect(['Pink Eyed White (PEW)', 'Hermelin', 'Himalaya']).not.toContain(name)
}) })
it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => { it('unknown second marker allele defaults UNMARKED, not Schecke (marker-aware)', () => {
@@ -410,11 +405,9 @@ 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 -> specific variety, not category (GEN-4 update)', () => { it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
// 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(
'Kohlfuchsschimmel', 'Fuchsschimmel',
) )
}) })
}) })
@@ -552,16 +545,15 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
}) })
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => { it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
// hom cchm/cchm + pp — A/D/E/G-independent (REW-2: Julian: "egal ob AA oder aa") // hom cchm/cchm + pp
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold (A-) expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
expect(name('aa cchmcchm DD EE GG pp spsp rere')).toBe('REW') // aa: REW-2 verification
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-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-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-Apricot
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
// het cchm/ch + pp (Julian: also REW) // het cchm/ch + pp (Julian: also REW)
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW') expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
// ch/ch + pp (Julian: also REW; PEW renamed to REW in catalog per GEN-4c) // 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')
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) // Counterproof: full C present → NOT REW (residual pigment)
@@ -569,21 +561,6 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp 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', () => { 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 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 Goldfuchs')
@@ -597,15 +574,15 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => { it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
// Fuchsschimmel: E=[ef,ef] hom // Fuchsschimmel: E=[ef,ef] hom
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
'AA CC DD e[f]e[f] GG PP spsp', 'AA CC DD e[f]e[f] GG PP spsp rere',
) )
// C-locus het: cchm + ch // C-locus het: cchm + ch
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[h] DD EE GG PP spsp', 'aa c[chm]c[h] DD EE GG PP spsp rere',
) )
// C-locus hom cchm // C-locus hom cchm
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
'aa c[chm]c[chm] DD EE GG PP spsp', 'aa c[chm]c[chm] DD EE GG PP spsp rere',
) )
}) })
@@ -615,24 +592,24 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Display must swap to [e, ef] per breeder convention. // Display must swap to [e, ef] per breeder convention.
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere') const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp') expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
}) })
it('E+e stays Ee (E dominant over e, no swap needed)', () => { it('E+e stays Ee (E dominant over e, no swap needed)', () => {
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe( expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
'aa CC DD Ee GG PP spsp', 'aa CC DD Ee GG PP spsp rere',
) )
}) })
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => { 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( expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
'aa CC DD Ee[f] GG PP spsp', 'aa CC DD Ee[f] GG PP spsp rere',
) )
}) })
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ───────────────────── // ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp)', () => { it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
const display = 'aa C- D- ee[f] Gg Pp spsp' const display = 'aa C- D- ee[f] Gg Pp spsp rere'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.E).toEqual(['ef', 'e']) expect(g.E).toEqual(['ef', 'e'])
expect(g.C).toEqual(['C', '?']) expect(g.C).toEqual(['C', '?'])
@@ -640,16 +617,16 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is. // 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', () => { 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' const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.C).toEqual(['cchm', 'ch']) expect(g.C).toEqual(['cchm', 'ch'])
expect(g.E).toEqual(['E', 'e']) expect(g.E).toEqual(['E', 'e'])
expect(toDisplayString(g)).toBe(display) expect(toDisplayString(g)).toBe(display)
}) })
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp', () => { it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
const display = 'aa Cc[h] dd EE Gg P- Spsp' const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
const g = fromDisplayString(display) const g = fromDisplayString(display)
expect(g.C).toEqual(['C', 'ch']) expect(g.C).toEqual(['C', 'ch'])
expect(g.D).toEqual(['d', 'd']) expect(g.D).toEqual(['d', 'd'])
@@ -672,7 +649,7 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
it('e[-] standalone: parses as [e,?], displays e-', () => { it('e[-] standalone: parses as [e,?], displays e-', () => {
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere') const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
expect(g.E).toEqual(['e', '?']) expect(g.E).toEqual(['e', '?'])
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp') expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
}) })
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => { it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
@@ -684,6 +661,6 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
expect(g.C).toEqual(['cchm', 'cchm']) expect(g.C).toEqual(['cchm', 'cchm'])
expect(g.D).toEqual(['D', 'd']) expect(g.D).toEqual(['D', 'd'])
expect(g.Sp).toEqual(['Sp', 'sp']) expect(g.Sp).toEqual(['Sp', 'sp'])
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp') expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
}) })
}) })

View File

@@ -51,7 +51,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
// GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is // GEN-3a: 'Schwarzschimmel' REMOVED (breeder C5: no such variety; efef base is
// Orangeschimmel — see the GEN-2 block below). This was an authorized exception // Orangeschimmel — see the GEN-2 block below). This was an authorized exception
// to the frozen-name rule; the ColorVariety seed drops it too. // to the frozen-name rule; the ColorVariety seed drops it too.
{ name: 'REW', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' }, { name: 'Pink Eyed White (PEW)', english: 'Pink Eyed White', tokens: { C: 'ch', P: 'p' }, image: 'rotaugen-weiss-pew-d-sep-e-sep.jpg' },
{ name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' }, { name: 'Hermelin', english: 'Dark Tailed White', tokens: { A: 'a', C: 'ch', D: 'D', P: 'P' }, image: 'hermelin.jpeg' },
{ name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' }, { name: 'Himalaya', english: 'Himalayan', tokens: { A: 'A', C: 'ch', D: 'D', P: 'P' }, image: 'himalaya.jpg' },
{ name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' }, { name: 'Zobel', english: 'Sable', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel.jpeg' },
@@ -78,9 +78,8 @@ 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 (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5). // GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc. { name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
{ 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: 'Dilute Agouti', 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' },
@@ -95,7 +94,6 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Dilute Polarfuchs', 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' },
@@ -155,12 +153,10 @@ 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 uses the PHENOTYPICALLY EXPRESSED * a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant * Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog * (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel' * raw pair, which runs before this.)
* 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
@@ -172,9 +168,7 @@ 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'
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype; if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
// 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)
@@ -187,12 +181,10 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
} }
/** /**
* E-locus family: used to scope the catalog search to E-aware entries. * GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus * when other loci are unknown (so genotypes never fall through to "Unbekannt").
* implies a non-dominant extension pair, or null for full-extension/unknown. * ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge. * Returns null when E is dominant (full colour) or fully unknown.
* 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
@@ -219,10 +211,7 @@ 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)
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are return base?.name ?? family
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
return base?.name ?? null
} }
/** /**
@@ -268,7 +257,7 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent. // AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
// cchm/cchm + pp → REW (CP varieties with pink-eye) // cchm/cchm + pp → REW (CP varieties with pink-eye)
// cchm/ch + pp → REW (het colourpoint + pink-eye) // cchm/ch + pp → REW (het colourpoint + pink-eye)
// ch/ch + pp → REW (Julian confirmed PEW=REW; the 'REW' catalog entry matches here) // 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). // Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
const [c0, c1] = resolvedPair(g, 'C') const [c0, c1] = resolvedPair(g, 'C')
const [p0, p1] = resolvedPair(g, 'P') const [p0, p1] = resolvedPair(g, 'P')
@@ -279,15 +268,7 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
} }
const baseName = colourpointName(g) ?? baseColourFor(g) const baseName = colourpointName(g) ?? baseColourFor(g)
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as if (!baseName) {
// 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(' ')

View File

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

View File

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

View File

@@ -101,21 +101,18 @@ function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
} }
/** /**
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp". * Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
* The Re locus is OMITTED when wild-type (re/re) — Julian: only show Rex when a * The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
* Rex allele is present (Rere/ReRe). Matches the 7-locus notation used by the * the colour catalog stay byte-identical; it only appears for WP/Sls carriers
* breeder. The Sls locus is likewise omitted when wild-type (sl/sl) so legacy * (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
* 8-locus strings and the colour catalog stay byte-identical; it only appears for * GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
* WP/Sls carriers (e.g. "… spsp Slsl"). Round-trips: missing Re → re/re; missing * convention) — e.g. ['C','?'] renders "C-".
* Sls → sl/sl. GEN-3c: unknown alleles stored as '?' displayed as '-' (breeder * GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
* convention). GEN-3h: bracket notation (e[f], c[chm], c[h]); E-locus display * E-locus display order is E > e > e[f] (e before e[f] in het pairs).
* order E > e > e[f].
*/ */
export function toDisplayString(g: Genotype): string { export function toDisplayString(g: Genotype): string {
return LOCUS_ORDER.filter( return LOCUS_ORDER.filter(
(locus) => (locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
!(locus === 'Re' && g.Re[0] === 're' && g.Re[1] === 're') &&
!(locus === 'Sls' && g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
) )
.map((locus) => { .map((locus) => {
const [a, b] = displayPair(locus, g[locus]) const [a, b] = displayPair(locus, g[locus])

View File

@@ -27,7 +27,7 @@ import { getInbreedingCoefficient } from '../api/pedigree'
import type { Gender, Gerbil } from '../api/types' import type { Gender, Gerbil } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, toDisplayString } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
ancestorsAt, ancestorsAt,
@@ -335,11 +335,7 @@ export default function StammbaumPage() {
/> />
)} )}
</div> </div>
<ul className="stammbaum-hints"> <p className="stammbaum-hint">{t.tapHint}</p>
<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. */}
@@ -381,13 +377,7 @@ function PedigreeCard({
<div className="pedigree-card__body"> <div className="pedigree-card__body">
<div className="pedigree-card__name"> <div className="pedigree-card__name">
<SexIcon gender={g.gender} /> <SexIcon gender={g.gender} />
<Link <span className="pedigree-card__nametext">{g.name || de.pages.gerbils.nameless}</span>
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
@@ -512,11 +502,7 @@ function PrintCell({
</div> </div>
)} )}
{farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>} {farbschlag && <div className="stammbaum-print__sub">{farbschlag}</div>}
{g.genotype && gen <= 2 && ( {g.genotype && gen <= 2 && <div className="stammbaum-print__geno">{g.genotype}</div>}
<div className="stammbaum-print__geno">
{toDisplayString(fromDisplayString(g.genotype))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -128,6 +128,7 @@ export default function WurfDetailPage() {
</ul> </ul>
)} )}
<h3>{t.detail.expectedColors}</h3>
{expected ? ( {expected ? (
<BreedingResultView result={expected} title={t.detail.expectedColors} /> <BreedingResultView result={expected} title={t.detail.expectedColors} />
) : ( ) : (

View File

@@ -34,15 +34,10 @@
color: var(--color-text); color: var(--color-text);
} }
.stammbaum-hints { .stammbaum-hint {
list-style: none; font-size: 0.8rem;
padding: 0;
margin: 0.4rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.1rem 1rem;
font-size: 0.78rem;
color: var(--color-text-muted); color: var(--color-text-muted);
margin: 0.4rem 0 0;
} }
/* ── Zeichenfläche ────────────────────────────────────────────── */ /* ── Zeichenfläche ────────────────────────────────────────────── */
@@ -139,12 +134,6 @@
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 {
@@ -180,27 +169,20 @@
.pedigree-card__expand { .pedigree-card__expand {
flex: none; flex: none;
margin-left: auto; margin-left: auto;
width: 40px; width: 32px;
height: 40px; height: 32px;
padding: 0;
border-radius: 50%; border-radius: 50%;
border: none; border: 1px solid var(--color-accent);
background: var(--color-accent); background: var(--color-accent-soft);
color: #fff; color: var(--color-accent);
font-size: 1.3rem; font-size: 1.05rem;
line-height: 1; line-height: 1;
cursor: pointer; 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:hover {
.pedigree-card__expand:focus-visible { background: var(--color-accent);
opacity: 0.82; color: #fff;
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) ──

View File

@@ -13,9 +13,6 @@ export interface ChipColor {
const CHIP_COLORS: Record<string, ChipColor> = { const CHIP_COLORS: Record<string, ChipColor> = {
// Colourpoint / C-Serie // Colourpoint / C-Serie
// GEN-4c: PEW renamed to REW in catalog (Julian: PEW=REW). Both kept here for
// backward-compat during DB migration (old animals may still carry the old name).
'REW': { bg: '#f7f3ec', fg: '#8a7d6b' },
'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' }, 'Pink Eyed White (PEW)': { bg: '#f7f3ec', fg: '#8a7d6b' },
Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' }, Hermelin: { bg: '#f3ede2', fg: '#8a7d6b' },
Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' }, Himalaya: { bg: '#efe6d8', fg: '#8a7d6b' },

View File

@@ -355,9 +355,6 @@ 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',
@@ -787,9 +784,9 @@ export const de = {
}, },
unknownFarbschlag: 'Unbekannter Farbschlag', unknownFarbschlag: 'Unbekannter Farbschlag',
}, },
// ── FEAT-14 / CHARAKTERBOGEN-2: Charakterbogen — Eigenschaften + Notiz, speist KI-Verkaufstext ── // ── FEAT-14 (Kevin): Charakterbogen — Eigenschaften + Notiz, speist den KI-Verkaufstext ──
// Traits: stabile KEYS (gespeichert, NIEMALS umbenennen) + deutsche LABELS (UI + KI-Prompt). // Traits: stabile KEYS (gespeichert) + deutsche LABELS (UI + KI-Prompt).
// Neue Traits = neuen Eintrag hinzufügen; warn:true = Warnsignal (visuell abgesetzt). // Erweitern = eine Zeile in der Liste; Julian verfeinert die Auswahl.
character: { character: {
sectionTitle: 'Charakter & Eigenschaften', sectionTitle: 'Charakter & Eigenschaften',
noteLabel: 'Notizen zum Charakter', noteLabel: 'Notizen zum Charakter',
@@ -797,53 +794,22 @@ 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.',
warnLabel: '⚠ Warnsignal', traits: [
traitCategories: [ { key: 'zutraulich', label: 'zutraulich' },
{ { key: 'handzahm', label: 'handzahm' },
category: 'Sozialverhalten', { key: 'neugierig', label: 'neugierig' },
traits: [ { key: 'aufgeschlossen', label: 'aufgeschlossen' },
{ key: 'dominant', label: 'dominant (Leittier)' }, { key: 'ruhig', label: 'ruhig / ausgeglichen' },
{ key: 'rangniedrig', label: 'rangniedrig / unterwürfig' }, { key: 'lebhaft', label: 'lebhaft / aktiv' },
{ key: 'sozialkompetent', label: 'sozialkompetent / gut sozialisiert' }, { key: 'verschmust', label: 'verschmust' },
{ key: 'schwer-vergesellschaftbar', label: 'schwer vergesellschaftbar', warn: true }, { key: 'eigenstaendig', label: 'eigenständig' },
], { key: 'anfaengergeeignet', label: 'anfängergeeignet' },
}, { key: 'futterfreudig', label: 'futterfreudig' },
{ { key: 'buddelt', label: 'buddelt gern' },
category: 'Eignung & Umgang', { key: 'klettert', label: 'klettert gern' },
traits: [ { key: 'laufrad', label: 'läuft gern im Laufrad' },
{ key: 'anfaengergeeignet', label: 'anfängergeeignet' }, { key: 'vertraeglich', label: 'gut verträglich' },
{ key: 'erfahrene-halter', label: 'für erfahrene Halter' }, { key: 'schreckhaft', label: 'schreckhaft' },
{ 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 ── // ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──

View File

@@ -112,56 +112,6 @@
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci", "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", "genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian 2026-06-07 — HUMANQUESTION D6" "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)"
},
{
"name": "Kazumi von den Kleinen Chaoten",
"dob": "23.04.2013",
"decision": "voller Genotyp von der Züchterin — löst die 4 strittigen Loci: A=Aa, G=GG, P=PP, Sp=Spsp",
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Filou von den Kleinen Chaoten",
"dob": "24.11.2014",
"decision": "Sterbedatum = 31.08.2019 (die 31.10.2019-Variante war falsch); Gencode war einig",
"dateOfDeath": "31.08.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Sokrates von den Kleinen Chaoten",
"dob": "14.12.2015",
"decision": "D-Locus = D- (nicht Dd) + Sterbedatum = 20.05.2019 (nicht 2020). Genotyp = die einigen Loci aus dem Extrakt mit D auf D- gesetzt (Uw→G normalisiert).",
"genotype": "aa Cc[-] D- ee Gg Pp spsp",
"dateOfDeath": "20.05.2019",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Percy of little runners",
"dob": "16.12.2017",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "aa Cc[chm] DD Ee Gg Pp Spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Iwana of little runners",
"dob": "02.10.2018",
"decision": "P-Locus = Pp (die Quellen unterschieden sich nur bei P: PP // Pp)",
"genotype": "Aa CC DD Ee Gg Pp spsp",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
},
{
"name": "Osamu von den Kleinen Chaoten",
"dob": "10.12.2015",
"decision": "Sterbedatum = 18.12.2020 (die 01.10.2020-Variante war falsch); Gencode war einig, taub-Flag bleibt via 'Vorhandensein gewinnt'",
"dateOfDeath": "18.12.2020",
"source": "Julian/Züchterin 2026-06-07 — HUMANQUESTION D7"
} }
] ]
} }

View File

@@ -1,312 +0,0 @@
#!/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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
t = t.replace("&apos;", "'").replace("&quot;", '"')
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("&amp;", "&").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("&amp;", "&").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()

View File

@@ -1,77 +0,0 @@
"""Tests for extract_docx.py — run: python test_extract_docx.py"""
import sys
import os
# Require the docx to exist; skip if not present (CI won't have it)
DOCX = os.path.join(r"C:\Users\gulum\dev",
"Wurfchronik der Kleinen Chaoten im Detail.docx")
SKIP = not os.path.isfile(DOCX)
import extract_docx as ed
failed = 0
def check(name, cond):
global failed
print(("ok: " if cond else "FAIL: ") + name)
if not cond:
failed += 1
# --- _norm_dob ---
check("norm_dob 2-digit year", ed._norm_dob("12.09.21") == "12.09.2021")
check("norm_dob 4-digit year", ed._norm_dob("07.04.2019") == "07.04.2019")
check("norm_dob empty", ed._norm_dob("") == "")
# --- _LAST_DATE_RE ---
check("last date: simple", ed._LAST_DATE_RE.findall("07.04.2019") == ["07.04.2019"])
check("last date: dual-day", ed._LAST_DATE_RE.findall("16./17.03.2021") == ["17.03.2021"])
check("last date: dual-month", ed._LAST_DATE_RE.findall("31.05/*01.06.2023") == ["01.06.2023"])
# --- _is_internal ---
check("ZT is internal", ed._is_internal("ZT"))
check("BLEIBT is internal", ed._is_internal("BLEIBT"))
check("VG: is internal", ed._is_internal("VG: Partner"))
check("real name not internal", not ed._is_internal("Marion Teichmann"))
# --- LITTER_RE ---
cases = [
("D19-Wurf *07.04.2019Von: Xhemile gen. Chanel v.d. Kleinen Chaoten & Omero v.d. Kleinen Chaoten WS: 2/4Notiz:", "2/4", "07.04.2019"),
("-Wurf *16./17.03.2021Von: Victoria Welby v.d. Kleinen Chaoten & Patch v.d. Kleinen Chaoten WS: /5Notiz:", "/5", "16./17.03.2021"),
("S22-Wurf *31.05/*01.06.2023Von: Velvet v.d. Kleinen Chaoten & Vance Sohn v.d. Kleinen ChaotenWS: 3/3", "3/3", "31.05/*01.06.2023"),
("Q21-Wurf *21.03.2022Von: Belica gen. Emi v.d. Kleinen Chaoten & Zac gen. Action v.d. Kleinen ChaotenWS: 2/4Notiz:", "2/4", "21.03.2022"),
]
for para, expected_ws, _ in cases:
m = ed.LITTER_RE.search(para)
ws = m.group(5).replace(" ", "") if m else None
check(f"LITTER_RE matches: {para[:50]}...", ws == expected_ws)
if SKIP:
print("(Skipping live-docx tests: file not found)")
else:
litters, animals = ed.extract(DOCX)
check("93 litters extracted", len(litters) == 93)
check("All litters have wsCode", all(l["wsCode"] for l in litters))
check("All litters have dob", all(l["dob"] for l in litters))
check(">200 named animals", len(animals) >= 200)
check(">150 animals with owner", sum(1 for a in animals if a["owner"]) >= 150)
check(">20 animals with death date", sum(1 for a in animals if a["deathDate"]) >= 20)
# Verify first litter
d19 = next((l for l in litters if l["litterId"] == "D19-Wurf"), None)
check("D19-Wurf found", d19 is not None)
check("D19-Wurf dob correct", d19 and d19["dob"] == "07.04.2019")
check("D19-Wurf wsCode = 2/4", d19 and d19["wsCode"] == "2/4")
check("D19-Wurf mother contains Xhemile", d19 and "Xhemile" in d19["motherName"])
# Verify Eddie in animals
eddie = next((a for a in animals if a["name"] == "Eddie" and a["wsCode"] == "2/4"), None)
check("Eddie found in D19-Wurf", eddie is not None)
check("Eddie gender=male (Zobel* suffix)", eddie and eddie["gender"] == "male")
check("Eddie abgabeDate", eddie and eddie["abgabeDate"] == "12.09.2021")
# Flash death date
flash = next((a for a in animals if a["name"] == "Flash" and a["wsCode"] == "3/3"), None)
check("Flash death date extracted", flash and flash["deathDate"] == "16.09.2023")
check("Flash death cause extracted", flash and "Tumor" in flash["deathCause"])
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)
print("\nALL PASS")