Compare commits
25 Commits
865b3831c8
...
feature/ge
| Author | SHA1 | Date | |
|---|---|---|---|
| ff42341c47 | |||
| 5c327cd8d8 | |||
| b12b5d720d | |||
| b7ebfd3057 | |||
| d268bbc122 | |||
| a638cf2c52 | |||
| efbf24ca39 | |||
| 22e033b26a | |||
| ee7f0aeab4 | |||
| 454f6e0d60 | |||
| c652afbbc7 | |||
| 545db4132f | |||
| ddc4676f87 | |||
| 083ffac47a | |||
| b6178b630b | |||
| 55d08a5dc3 | |||
| 532cbf3bcf | |||
| d1baba686f | |||
| 8b867b9058 | |||
| ea3596ca0d | |||
| e6b513ed5d | |||
| 52303b90b2 | |||
| 4b6a07544d | |||
| 8f90821081 | |||
| 9dda78b28a |
@@ -48,6 +48,26 @@ jobs:
|
||||
- name: Build
|
||||
run: dotnet build GerbilManager.slnx --no-restore -c Release
|
||||
|
||||
- name: dotnet-ef Tool installieren
|
||||
# 'tool update' ist idempotent (installiert wenn fehlend, sonst aktualisiert) —
|
||||
# auf dem SELF-HOSTED Runner persistieren --global Tools, daher schlug 'tool install'
|
||||
# ab dem 2. Lauf mit 'already installed' fehl und kippte den Backend-Job.
|
||||
# GITHUB_PATH-Zeile: global tools landen in ~/.dotnet/tools, das auf dem Runner-Container
|
||||
# nicht im PATH ist — einmalig eintragen, damit alle Folge-Steps 'dotnet ef' finden.
|
||||
run: |
|
||||
dotnet tool update --global dotnet-ef --version 10.0.*
|
||||
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
|
||||
|
||||
- name: DB-3 EF Migrations Drift-Check
|
||||
# Fails CI if the EF model diverges from the snapshot (i.e. a code change touched
|
||||
# entities/OnModelCreating without generating a matching migration). Catches exactly
|
||||
# the class of drift the SQLite/EnsureCreated test host is blind to.
|
||||
run: >
|
||||
dotnet ef migrations has-pending-model-changes
|
||||
--project GerbilManagerWebAPI
|
||||
--startup-project GerbilManagerWebAPI
|
||||
--no-build --configuration Release
|
||||
|
||||
- name: Tests ausfuehren
|
||||
run: dotnet test GerbilManager.slnx --no-build -c Release --logger "console;verbosity=normal"
|
||||
|
||||
|
||||
@@ -103,6 +103,31 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DB5_Litter_ExternalRef_set_and_used_for_idempotency()
|
||||
{
|
||||
// DB-5: litters must have ExternalRef set to the source litter id on first import,
|
||||
// and subsequent runs must detect them via ExternalRef (not just Name+Date).
|
||||
using var db = NewDb();
|
||||
await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
|
||||
// ExternalRef is set on created litters
|
||||
var litters = await db.Litters.ToListAsync();
|
||||
Assert.All(litters, l => Assert.NotNull(l.ExternalRef));
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L1");
|
||||
Assert.Contains(litters, l => l.ExternalRef == "L2");
|
||||
|
||||
// Simulate the "Name+Date lookup would still work, but ExternalRef is now primary":
|
||||
// mutate Name to something different — Name+Date fallback would fail, ExternalRef must catch it.
|
||||
foreach (var l in litters) l.Name = "Geänderter Name";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-import: litters detected as existing via ExternalRef even though Name changed
|
||||
var second = await new ImportService(db, _dir, _dir).RunAsync(execute: true);
|
||||
Assert.Equal(0, second.Litters.Created);
|
||||
Assert.Equal(2, await db.Litters.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_persists_deaf_flag_and_preserves_sls_and_tags()
|
||||
{
|
||||
|
||||
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
208
GerbilManager.Tests/NameSuggestionTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Names;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown
|
||||
/// fence strip), 503-not-configured path, upstream-error path.
|
||||
/// </summary>
|
||||
public class NameSuggestionTests
|
||||
{
|
||||
// ── Prompt assembly ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SystemPrompt_verlangt_reines_JSON_ohne_Erklärungen()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildSystemPrompt();
|
||||
Assert.Contains("reinen JSON-Array", prompt);
|
||||
Assert.Contains("KEINE Markdown-Code-Blöcke", prompt);
|
||||
Assert.Contains("name", prompt);
|
||||
Assert.Contains("meaning", prompt);
|
||||
Assert.Contains("origin", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt("A", "female", "norn,mythg", 6);
|
||||
Assert.Contains("6", prompt);
|
||||
Assert.Contains("\"A\"", prompt);
|
||||
Assert.Contains("weibliche", prompt);
|
||||
Assert.Contains("norn,mythg", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_ohne_optionale_Parameter_ist_gültig()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, null, null, 5);
|
||||
Assert.Contains("5", prompt);
|
||||
Assert.DoesNotContain("Buchstaben", prompt);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserPrompt_gender_any_wird_nicht_im_Prompt_erwähnt()
|
||||
{
|
||||
var prompt = NameSuggestionService.BuildUserPrompt(null, "any", null, 3);
|
||||
Assert.DoesNotContain("weibliche", prompt);
|
||||
Assert.DoesNotContain("männliche", prompt);
|
||||
}
|
||||
|
||||
// ── JSON parsing ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_verarbeitet_reines_JSON()
|
||||
{
|
||||
var json = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"}]""";
|
||||
var result = NameSuggestionService.ParseSuggestions(json);
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.Equal("Astrid", result[0].Name);
|
||||
Assert.Equal("göttliche Stärke", result[0].Meaning);
|
||||
Assert.Equal("Altnordisch", result[0].Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_json_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Aiko\",\"meaning\":\"kleine Geliebte\",\"origin\":\"Japanisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Aiko", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_strippt_generische_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var result = NameSuggestionService.ParseSuggestions(fenced);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Luna", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_toleriert_umgebenden_Text_vor_Array()
|
||||
{
|
||||
var messy = "Hier sind die Namen:\n[{\"name\":\"Sol\",\"meaning\":\"Sonne\",\"origin\":\"Nordisch\"}]\nHoffnungslos.";
|
||||
var result = NameSuggestionService.ParseSuggestions(messy);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Sol", result![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSuggestions_gibt_null_zurück_bei_ungültigem_JSON()
|
||||
{
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("kein json"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions("{\"name\":\"X\"}"));
|
||||
Assert.Null(NameSuggestionService.ParseSuggestions(""));
|
||||
}
|
||||
|
||||
// ── 503: nicht konfiguriert ───────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "key", "model")]
|
||||
[InlineData("https://api.example.com/v1", null, "model")]
|
||||
[InlineData("https://api.example.com/v1", "key", null)]
|
||||
[InlineData(null, null, null)]
|
||||
public async Task SuggestAsync_gibt_NotConfigured_wenn_AI_Key_fehlt(
|
||||
string? baseUrl, string? apiKey, string? model)
|
||||
{
|
||||
var service = CreateService(baseUrl, apiKey, model,
|
||||
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 5);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.NotConfigured, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
// ── Gemini-Antwort wird geparst ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_valide_JSON_Antwort()
|
||||
{
|
||||
var payload = """[{"name":"Astrid","meaning":"göttliche Stärke","origin":"Altnordisch"},{"name":"Aiko","meaning":"kleine Geliebte","origin":"Japanisch"}]""";
|
||||
var handler = new StubHandler(_ => Canned(payload));
|
||||
var service = CreateService("https://generativelanguage.googleapis.com/v1beta/openai", "k", "gemini-2.0-flash", handler);
|
||||
|
||||
var result = await service.SuggestAsync("A", "female", "norn,japa", 2);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.NotNull(result.Suggestions);
|
||||
Assert.Equal(2, result.Suggestions!.Count);
|
||||
Assert.Equal("Astrid", result.Suggestions[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_parst_JSON_in_Markdown_Fence()
|
||||
{
|
||||
var fenced = "```json\n[{\"name\":\"Luna\",\"meaning\":\"Mond\",\"origin\":\"Lateinisch\"}]\n```";
|
||||
var handler = new StubHandler(_ => Canned(fenced));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 1);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.Ok, result.Status);
|
||||
Assert.Equal("Luna", result.Suggestions![0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_ungültigem_JSON()
|
||||
{
|
||||
var handler = new StubHandler(_ => Canned("das ist kein json"));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
Assert.Null(result.Suggestions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuggestAsync_gibt_UpstreamError_bei_HTTP_Fehler()
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests));
|
||||
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||
|
||||
var result = await service.SuggestAsync(null, null, null, 3);
|
||||
|
||||
Assert.Equal(NameSuggestionStatus.UpstreamError, result.Status);
|
||||
}
|
||||
|
||||
// ── Helfer ────────────────────────────────────────────────────────────
|
||||
|
||||
private static NameSuggestionService CreateService(
|
||||
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||
{
|
||||
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||
return new NameSuggestionService(new HttpClient(handler), options);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Canned(string content)
|
||||
{
|
||||
var completion = new
|
||||
{
|
||||
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||
};
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||
Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,13 @@ public class ApplicationContext : DbContext
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// DB-4: ICU German collation on user-visible / searched text columns.
|
||||
// "de-x-icu" gives correct German sort order (ä between a and b) and locale-aware
|
||||
// case-folding (lower('Ä')='ä'). Applied only on Npgsql (Postgres); SQLite does not
|
||||
// support custom collation names and would fail EnsureCreated on the test host.
|
||||
const string DeIcu = "de-x-icu";
|
||||
bool isNpgsql = Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) ?? false;
|
||||
|
||||
modelBuilder.Entity<Gerbil>(e =>
|
||||
{
|
||||
// Enums persisted as their string names (readable, Gridify-friendly).
|
||||
@@ -58,6 +65,14 @@ public class ApplicationContext : DbContext
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
e.Property(g => g.Name).UseCollation(DeIcu);
|
||||
e.Property(g => g.NameSearch).UseCollation(DeIcu);
|
||||
e.Property(g => g.OriginBreeder).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
// FEAT-14: character traits stored as a JSON text column (works on both
|
||||
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
|
||||
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||
@@ -95,6 +110,12 @@ public class ApplicationContext : DbContext
|
||||
.HasForeignKey(l => l.FatherId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(l => l.Mother).WithMany()
|
||||
.HasForeignKey(l => l.MotherId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// DB-5: ExternalRef = source litter id from extract.py.
|
||||
// Unique (filtered, nulls allowed for manually-entered litters).
|
||||
e.HasIndex(l => l.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
@@ -176,6 +197,15 @@ public class ApplicationContext : DbContext
|
||||
modelBuilder.Entity<MailSettings>()
|
||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
modelBuilder.Entity<ColorVariety>()
|
||||
.Property(v => v.Name).UseCollation(DeIcu);
|
||||
modelBuilder.Entity<Contact>()
|
||||
.Property(c => c.Name).UseCollation(DeIcu);
|
||||
}
|
||||
|
||||
SeedColorVarieties(modelBuilder);
|
||||
}
|
||||
|
||||
|
||||
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using GerbilManagerWebAPI.Names;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||
///
|
||||
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class NamesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/names/suggest", async (
|
||||
string? letter,
|
||||
string? gender,
|
||||
string? usages,
|
||||
int count,
|
||||
NameSuggestionService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("Names");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>d5041cff-22a2-4aba-80ff-903ce5203eb3</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Gridify" Version="2.19.1" />
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -71,17 +71,21 @@ namespace GerbilManagerWebAPI.Import
|
||||
else if (conf == "niedrig") dateOnly++;
|
||||
}
|
||||
|
||||
// existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such
|
||||
// column, so we key litter idempotency on the stable (Name + Date) pair instead.
|
||||
// existing rows (idempotency).
|
||||
var existingGerbilExtRefs = await _db.Gerbils
|
||||
.Where(g => g.ExternalRef != null)
|
||||
.Select(g => g.ExternalRef!).ToListAsync();
|
||||
var existingGerbilSet = existingGerbilExtRefs.ToHashSet();
|
||||
|
||||
var existingLitterKeys = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterKeys
|
||||
// DB-5: Litters now carry ExternalRef (= source litter id from extract.py).
|
||||
// Primary idempotency: ExternalRef. Fallback: Name+Date for litters created before DB-5.
|
||||
var existingLitterData = await _db.Litters
|
||||
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
|
||||
var existingLitterKeySet = existingLitterData
|
||||
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
|
||||
var existingLitterExtRefSet = existingLitterData
|
||||
.Where(x => x.ExternalRef != null)
|
||||
.Select(x => x.ExternalRef!).ToHashSet();
|
||||
|
||||
// colour-variety name -> id (case-insensitive)
|
||||
var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync();
|
||||
@@ -94,10 +98,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
|
||||
|
||||
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
|
||||
// Idempotency (DB-5): ExternalRef-match is primary (stable source id); Name+Date is the
|
||||
// fallback for litters created before DB-5 (those have ExternalRef=null in the DB).
|
||||
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date,
|
||||
// so their existingLitterKeySet key was always "" → they were always counted as
|
||||
// "created" even though the execute block skipped them (date is DateOnly d = false).
|
||||
// Fix: skip undated litters early — they can never be created or linked to animals.
|
||||
// so skip them early — they can never be created or linked to animals.
|
||||
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
|
||||
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
|
||||
foreach (var sl in litters)
|
||||
@@ -107,7 +111,10 @@ namespace GerbilManagerWebAPI.Import
|
||||
|
||||
var name = $"Wurf {sl.LitterId}".Trim();
|
||||
var key = $"{name}|{date:yyyy-MM-dd}";
|
||||
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
|
||||
// DB-5: check ExternalRef first (stable, source-id-based); fall back to Name+Date
|
||||
// for litters imported before ExternalRef existed (those have ExternalRef = null).
|
||||
if (existingLitterExtRefSet.Contains(sl.Id) || existingLitterKeySet.Contains(key))
|
||||
{ littersExisting++; continue; }
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
litterIdMap[sl.Id] = id;
|
||||
@@ -122,6 +129,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
TotalBorn = sl.TotalBorn,
|
||||
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
|
||||
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
|
||||
ExternalRef = sl.Id, // DB-5: stable import key for future re-imports
|
||||
});
|
||||
}
|
||||
if (samples.Count < 8)
|
||||
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174843_AddGermanCollation.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGermanCollation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExternalRef",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "ColorVarieties",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
collation: "de-x-icu",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Litters_ExternalRef",
|
||||
table: "Litters",
|
||||
column: "ExternalRef",
|
||||
unique: true,
|
||||
filter: "\"ExternalRef\" IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Litters_ExternalRef",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalRef",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "OriginBreeder",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true,
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "NameSearch",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldNullable: true,
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Gerbils",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "Contacts",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "Name",
|
||||
table: "ColorVarieties",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldCollation: "de-x-icu");
|
||||
}
|
||||
}
|
||||
}
|
||||
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
1399
GerbilManagerWebAPI/Migrations/20260606174848_AddLitterExternalRef.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterExternalRef : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterLetter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LitterLetter",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LitterLetter",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,8 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
@@ -689,7 +690,8 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
@@ -777,16 +779,19 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("NameSearch")
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("OriginBreeder")
|
||||
.HasColumnType("text");
|
||||
.HasColumnType("text")
|
||||
.UseCollation("de-x-icu");
|
||||
|
||||
b.Property<Guid?>("OriginContactId")
|
||||
.HasColumnType("uuid");
|
||||
@@ -894,9 +899,15 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateOnly?>("ExpectedGoHomeDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("ExternalRef")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LitterLetter")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("MotherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -915,6 +926,10 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalRef")
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
b.HasIndex("FatherId");
|
||||
|
||||
b.HasIndex("MotherId");
|
||||
|
||||
@@ -25,5 +25,14 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
|
||||
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
|
||||
public string? PairingCode { get; set; }
|
||||
|
||||
/// <summary>DB-5: stable import source id (extract.py litter Id). Unique (filtered,
|
||||
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||
public string? ExternalRef { get; set; }
|
||||
|
||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||
public string? LitterLetter { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace GerbilManagerWebAPI.Names
|
||||
{
|
||||
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
||||
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
||||
}
|
||||
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Ai;
|
||||
using GerbilManagerWebAPI.SaleAd;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GerbilManagerWebAPI.Names
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini
|
||||
/// (the same OpenAiChatClient used by sale-ads and reply-drafts).
|
||||
/// Returns NotConfigured when the AI section is missing — callers map to 503.
|
||||
/// </summary>
|
||||
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> options)
|
||||
{
|
||||
private readonly OpenAiChatClient _client = new(http, options);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public async Task<NameSuggestionResult> SuggestAsync(
|
||||
string? letter, string? gender, string? usages, int count,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var aiResult = await _client.CompleteAsync(
|
||||
BuildSystemPrompt(),
|
||||
BuildUserPrompt(letter, gender, usages, count),
|
||||
ct);
|
||||
|
||||
if (aiResult.Status == AiCallStatus.NotConfigured)
|
||||
return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error);
|
||||
if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null)
|
||||
return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error);
|
||||
|
||||
var suggestions = ParseSuggestions(aiResult.Text);
|
||||
return suggestions is null
|
||||
? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.")
|
||||
: new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null);
|
||||
}
|
||||
|
||||
internal static string BuildSystemPrompt() =>
|
||||
"Du bist ein Helfer für Rennmaus-Züchter. " +
|
||||
"Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " +
|
||||
"KEINE Erklärungen, KEIN Text außerhalb des Arrays. " +
|
||||
"Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch).";
|
||||
|
||||
internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"Schlage {count} Rennmaus-Namen vor");
|
||||
if (!string.IsNullOrWhiteSpace(letter))
|
||||
sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen");
|
||||
if (!string.IsNullOrWhiteSpace(gender) &&
|
||||
!gender.Equals("any", StringComparison.OrdinalIgnoreCase))
|
||||
sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere");
|
||||
if (!string.IsNullOrWhiteSpace(usages))
|
||||
sb.Append($", aus den Kulturkreisen: {usages}");
|
||||
sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben ");
|
||||
sb.Append("(keine erfundenen oder zufälligen Namen). ");
|
||||
sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: ");
|
||||
sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
||||
/// its JSON output, then deserialises the array.
|
||||
/// </summary>
|
||||
internal static List<NameSuggestion>? ParseSuggestions(string raw)
|
||||
{
|
||||
var text = raw.Trim();
|
||||
|
||||
// Strip ```json ... ``` or ``` ... ``` fences.
|
||||
if (text.StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
var firstNewline = text.IndexOf('\n');
|
||||
if (firstNewline >= 0) text = text[(firstNewline + 1)..];
|
||||
if (text.EndsWith("```", StringComparison.Ordinal))
|
||||
text = text[..^3].TrimEnd();
|
||||
}
|
||||
|
||||
// Find the JSON array bounds defensively.
|
||||
var start = text.IndexOf('[');
|
||||
var end = text.LastIndexOf(']');
|
||||
if (start < 0 || end <= start) return null;
|
||||
text = text[start..(end + 1)];
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<NameSuggestion>>(text, JsonOpts);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
||||
|
||||
public sealed record NameSuggestionResult(
|
||||
NameSuggestionStatus Status,
|
||||
List<NameSuggestion>? Suggestions,
|
||||
string? Error);
|
||||
}
|
||||
@@ -45,6 +45,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||
@@ -100,6 +103,7 @@ app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ hinterlegt ist. Jede offene Frage unten zeigt, **was dadurch blockiert ist**.
|
||||
|
||||
| # | Was gebraucht wird | Wofür / was es freischaltet | Blockiert gerade |
|
||||
|---|---|---|---|
|
||||
| A1 | **Gemini API-Schlüssel** (aistudio.google.com → „API-Schlüssel erstellen"). | Schaltet **vier Funktionen auf einmal** frei | KI-Verkaufstexte, Charakterbogen-KI (fertig, schläft), Webseiten-Chat-Editor, KI-Antwortentwürfe im Posteingang |
|
||||
| A2 | **Abrechnung aktivieren? (EU/Deutschland)** Die Gemini-AGB verlangen für EU-Nutzer die **bezahlte Stufe** — bei eurem Volumen **praktisch 0 €**, aber eure Daten werden dann **nicht** zum Training genutzt. Empfehlung: **Abrechnung einschalten** (bleibt fast gratis). Alternative für volle Privatsphäre: lokales Modell (Ollama). | Datenschutz (v. a. E-Mail-Inhalte) + AGB-konform | dieselben KI-Funktionen wie A1 |
|
||||
| ~~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.) | — | — |
|
||||
| ~~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.) | — | — |
|
||||
| A3 | **Gmail App-Passwort.** Einmalig: Google-Konto → 2-Faktor-Bestätigung aktivieren → „App-Passwörter" → eines für „GerbilManager" erstellen → 16-stelligen Code an Michael. | Posteingang verbinden (Anfragen abrufen + Antworten senden) | E-Mail-Posteingang (live) |
|
||||
| A4 | **Domain-Name** (ist registriert ✔) + **Cloudflare-Konto & API-Token** (Berechtigung „Cloudflare Pages → Edit"). | Öffentliche Webseite auf Cloudflare veröffentlichen | Veröffentlichung der neuen Webseite (Ersatz für Jimdo) |
|
||||
| A5 | **TrueNAS / Gitea — Restfragen:** ~~(b) Gitea Actions?~~ ✅ **AKTIV seit 06.06. — CI läuft bereits** (Runner registriert, Backend+Frontend-Tests grün auf dem Runner). **NEU (b2): Docker-Push-Job braucht 2 Repo-Secrets** — in Gitea → Repo → Einstellungen → Actions → Secrets bitte `REGISTRY_USER` (dein Gitea-Login) und `REGISTRY_TOKEN` (Token mit `write:package`) anlegen, dann pusht die CI fertige Images in die Registry. Offen bleiben: (a) TrueNAS SCALE-Version? (c) Eigener Postgres-Container (empfohlen) oder bestehender NAS-Postgres? (d) Dataset-Pfad für Daten/Backups, Port 80 frei? | Manager auf dem NAS betreiben (Produktiv + Backups + CI) | NAS-Deployment (compose fertig; CI ✅ live) |
|
||||
@@ -126,10 +126,10 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
|
||||
| Tier | Konkreter Konflikt — was stimmt? |
|
||||
|---|---|
|
||||
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019** ↔ **14.01.2020** (= D5) |
|
||||
| Little Runner's Big Ben (*03.02.2020) | P-Locus: **PP** ↔ **Pp** |
|
||||
| Vance Jr. v.d. K.C. (*10.04.2022) | Scheckung: **Spsp** ↔ **spsp** (Schecke ja/nein) |
|
||||
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]** ↔ **ee[f]** · G-Locus: **Gg** ↔ **GG** · P-Locus: **P?** ↔ **PP** |
|
||||
| Skarlett v.d. K.C. (*14.07.2013) | Sterbedatum: **17.04.2016** ↔ **2018** |
|
||||
| ~~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.)*
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# GerbilManager Produktionskonfiguration
|
||||
# GerbilManager Produktionskonfiguration
|
||||
# Kopiere diese Datei nach .env und setze die Werte vor dem ersten Start.
|
||||
|
||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||
@@ -26,4 +26,4 @@ BACKUP_KEEP_DAYS=7
|
||||
# Leer lassen = KI deaktiviert (kein Fehler, nur 503 AiKeyMissing)
|
||||
AI__BaseUrl=
|
||||
AI__ApiKey=
|
||||
AI__Model=gemini-2.0-flash
|
||||
AI__Model=gemini-flash-latest
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# GerbilManager — TrueNAS Custom Application
|
||||
# GerbilManager — TrueNAS Custom Application
|
||||
# ==============================================
|
||||
# Vor dem ersten Start:
|
||||
# 1. Kopiere deploy/truenas/.env.example -> deploy/truenas/.env und setze die Werte.
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
# Leer lassen = KI deaktiviert (503 AiKeyMissing statt Fehler)
|
||||
AI__BaseUrl: "${AI__BaseUrl:-}"
|
||||
AI__ApiKey: "${AI__ApiKey:-}"
|
||||
AI__Model: "${AI__Model:-gemini-2.0-flash}"
|
||||
AI__Model: "${AI__Model:-gemini-flash-latest}"
|
||||
volumes:
|
||||
- photos:/data/photos
|
||||
- keys:/data/keys
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||
|
||||
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||
@@ -16,7 +16,7 @@ committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
||||
|
||||
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||
|---|---|---|---|
|
||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
||||
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-flash-latest` | Free Tier |
|
||||
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||
@@ -31,7 +31,7 @@ Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
||||
```powershell
|
||||
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||
$env:AI__ApiKey = '<schlüssel>'
|
||||
$env:AI__Model = 'gemini-2.0-flash'
|
||||
$env:AI__Model = 'gemini-flash-latest'
|
||||
```
|
||||
|
||||
oder per user-secrets im API-Projekt:
|
||||
@@ -46,5 +46,5 @@ services:
|
||||
environment:
|
||||
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||
AI__Model: gemini-2.0-flash
|
||||
AI__Model: gemini-flash-latest
|
||||
```
|
||||
|
||||
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
169
gerbil-manager-web/e2e/smoke-2.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* QA-SMOKE-2: Gezielter Live-Smoke der heute gemergten Änderungen (main e6b513e).
|
||||
* Läuft NUR im LIVE-Modus (E2E_BASE_URL gesetzt).
|
||||
*
|
||||
* Abgedeckte Änderungen:
|
||||
* 1. GEN-3h — Bracket-Notation: eef→ee[f], cchm→c[chm], C/cchm→Cc[chm]
|
||||
* 2. FORM-FIELDS — originBreeder + isDeaf im Tier-Editformular
|
||||
* 3. CR-2 — Partial-Update nullt keine bestehenden Felder mehr
|
||||
* 4. UI-POLISH — '(ohne Namen)'-Platzhalter für namenlose Tiere
|
||||
* 5. GEHEGE — Redirects + Nav (bereits in gehege-regression.spec; hier live-Verifikation)
|
||||
*
|
||||
* Read-Only soweit möglich. CR-2-Test: idempotentes Save (keine Wertänderung).
|
||||
*/
|
||||
import { de, expect, skipUnlessLive, test } from './fixtures'
|
||||
|
||||
const API = 'http://localhost:5179'
|
||||
|
||||
// ── 1. GEN-3h Bracket-Notation ───────────────────────────────────────────────
|
||||
|
||||
test('GEN-3h: eef zeigt ee[f] (nicht "eef") im Tier-Detail', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Roswitha — genotype: "aa CC D? eef Gg P? spsp ??"
|
||||
// eef = e/ef het → E>e>ef Rank → ee[f]
|
||||
await page.goto('/rennmaeuse/66ee246c-26f0-41ac-8b6c-c151d9287633')
|
||||
await expect(page.getByRole('heading', { name: 'Roswitha von den Kleinen Chaoten' })).toBeVisible()
|
||||
// Genotyp-Block soll e[f] enthalten, nicht rohes 'eef'
|
||||
const genoCode = page.locator('code').filter({ hasText: /e\[f\]/ })
|
||||
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||
// Rohes 'eef' darf nicht mehr im Code-Block stehen
|
||||
await expect(page.locator('code').filter({ hasText: /[^[]eef/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('GEN-3h: cchm/cchm zeigt c[chm]c[chm] im Tier-Detail (ZoneFire)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// ZoneFire — genotype: "Aa cchmcchm D? Ee Gg P? Spsp ??"
|
||||
await page.goto('/rennmaeuse/14d5cea9-0f87-4381-8afe-80f1d58f7b76')
|
||||
await expect(page.getByRole('heading', { name: 'ZoneFire' })).toBeVisible()
|
||||
const genoCode = page.locator('code').filter({ hasText: /c\[chm\]/ })
|
||||
await expect(genoCode).toBeVisible({ timeout: 8_000 })
|
||||
await expect(page.locator('code').filter({ hasText: /cchmcchm/ })).toBeHidden()
|
||||
})
|
||||
|
||||
test('GEN-3h: Cc[chm] + ee[f] korrekt bei Ethan (gemischter Locus)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Ethan — genotype: "Aa Ccchm D? eef Gg Pp Spsp ??"
|
||||
// Ccchm → Cc[chm]; eef → ee[f]
|
||||
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4')
|
||||
await expect(page.getByRole('heading', { name: 'Ethan von den Kleinen Chaoten' })).toBeVisible()
|
||||
const genoCode = page.locator('code')
|
||||
await expect(genoCode.filter({ hasText: /c\[chm\]/ })).toBeVisible({ timeout: 8_000 })
|
||||
await expect(genoCode.filter({ hasText: /e\[f\]/ })).toBeVisible()
|
||||
// Rohes Storage-Format darf nicht sichtbar sein
|
||||
await expect(genoCode.filter({ hasText: /Ccchm|eef/ })).toBeHidden()
|
||||
})
|
||||
|
||||
// ── 2. FORM-FIELDS: originBreeder + isDeaf im Formular ───────────────────────
|
||||
|
||||
test('FORM-FIELDS: Herkunft-Freitext + Gehörlos-Dropdown im Tier-Editformular', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessLive()
|
||||
// Ethan hat bereits originBreeder="Kleinen Chaoten", isDeaf=null
|
||||
await page.goto('/rennmaeuse/54ef9b08-0def-496e-95db-5107b732ddd4/bearbeiten')
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.form.editTitle })).toBeVisible({ timeout: 8_000 })
|
||||
|
||||
// Herkunft-Freitext (originBreeder) soll sichtbar und gefüllt sein
|
||||
const originInput = page.getByLabel(de.pages.gerbils.fields.originBreeder)
|
||||
await expect(originInput).toBeVisible()
|
||||
await expect(originInput).toHaveValue('Kleinen Chaoten')
|
||||
|
||||
// Gehörlos-Dropdown (isDeaf) soll sichtbar sein mit "Unbekannt"-Default
|
||||
// Gehörlos-Dropdown (isDeaf): label 'Gehörlos', aktuell leer (Unbekannt)
|
||||
const deafLabel = page.locator('label').filter({ hasText: de.pages.gerbils.fields.isDeaf })
|
||||
await expect(deafLabel).toBeVisible()
|
||||
// Dropdown-Select im Label-Kontext
|
||||
const deafSelect = deafLabel.locator('select')
|
||||
await expect(deafSelect).toBeVisible()
|
||||
})
|
||||
|
||||
// ── 3. CR-2: idempotentes Save nullt keine Felder ────────────────────────────
|
||||
|
||||
test('CR-2: Partial-PUT nullt keine bestehenden Felder (direkter API-Test)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Snapshot VORHER
|
||||
const before = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||
const snap = await before.json()
|
||||
expect(snap.originBreeder).toBe('Kleinen Chaoten')
|
||||
expect(snap.genotype).toBeTruthy()
|
||||
|
||||
// Partial-PUT: nur name + gender senden (minimal body, alle anderen Felder fehlen)
|
||||
// Mit PATCH-Semantik-Fix: fehlende Felder werden NICHT genullt
|
||||
const res = await page.request.put(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`, {
|
||||
data: {
|
||||
name: snap.name,
|
||||
gender: snap.gender,
|
||||
status: snap.status,
|
||||
isResident: snap.isResident,
|
||||
// ALLE anderen Felder absichtlich weggelassen → vor CR-2-Fix würden sie auf null gesetzt
|
||||
},
|
||||
})
|
||||
// PATCH-Semantik: 204 No Content (kein Body), fehlende Felder bleiben erhalten
|
||||
expect(res.status()).toBe(204)
|
||||
|
||||
// Re-Fetch und vergleichen
|
||||
const afterRes = await page.request.get(`${API}/gerbils/54ef9b08-0def-496e-95db-5107b732ddd4`)
|
||||
const updated = await afterRes.json()
|
||||
|
||||
expect(updated.originBreeder).toBe(snap.originBreeder) // muss erhalten bleiben
|
||||
expect(updated.genotype).toBe(snap.genotype) // muss erhalten bleiben
|
||||
expect(updated.dateOfBirth).toBe(snap.dateOfBirth) // muss erhalten bleiben
|
||||
expect(updated.litterId).toBe(snap.litterId) // muss erhalten bleiben
|
||||
expect(updated.externalRef).toBe(snap.externalRef) // muss erhalten bleiben
|
||||
|
||||
// Restore: idempotent (gleiche Werte gesetzt, kein Net-Change)
|
||||
})
|
||||
|
||||
// ── 4. UI-POLISH: '(ohne Namen)'-Platzhalter ─────────────────────────────────
|
||||
|
||||
test('UI-POLISH: namenlose Tiere zeigen "(ohne Namen)" in der Tierliste', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Alle Tiere anzeigen (inkl. externe), um namenlose zu sehen
|
||||
await page.goto('/rennmaeuse')
|
||||
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||
// "(ohne Namen)" Platzhalter soll mindestens einmal sichtbar sein
|
||||
// (Filter auf alle Tiere — ggf. "Externe anzeigen" aktivieren)
|
||||
const placeholder = page.getByText('(ohne Namen)')
|
||||
// Wenn nicht sofort sichtbar, Toggle "Externe anzeigen" probieren
|
||||
if (!(await placeholder.isVisible({ timeout: 3_000 }).catch(() => false))) {
|
||||
const toggle = page.locator('button, label').filter({ hasText: /extern|alle/i }).first()
|
||||
if (await toggle.isVisible()) await toggle.click()
|
||||
}
|
||||
await expect(placeholder.first()).toBeVisible({ timeout: 8_000 })
|
||||
})
|
||||
|
||||
test('UI-POLISH: namenloser Knoten im Stammbaum zeigt "(ohne Namen)"', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
// Stammbaum eines Tiers mit namenlosem Elternteil
|
||||
// Tier "46c646a9" ist namenlos + resident; sein Elternteil im Stammbaum-Kontext
|
||||
// Alternativ: Vance's Stammbaum enthält tiefe Äste, manche namenlos
|
||||
await page.goto('/rennmaeuse/afdaff89-0274-4778-8f2f-3957a81bf58e/stammbaum')
|
||||
await expect(page.locator('svg').first()).toBeVisible({ timeout: 12_000 })
|
||||
// Prüfe ob "(ohne Namen)" irgendwo im Baum erscheint
|
||||
const placeholder = page.getByText('(ohne Namen)')
|
||||
// Kein harter Fail wenn kein namenloser Knoten sichtbar — Tree kann mit Lazy-Gen-5+ variieren
|
||||
// Aber wenn vorhanden, muss der Text sauber sein (nicht leer)
|
||||
const visible = await placeholder.isVisible({ timeout: 5_000 }).catch(() => false)
|
||||
if (visible) {
|
||||
await expect(placeholder.first()).toBeVisible()
|
||||
}
|
||||
// Kein JS-Error auf der Seite (implizit durch erfolgreiche Navigation)
|
||||
})
|
||||
|
||||
// ── 5. GEHEGE: Live-Verifikation der Redirect/Nav-Änderungen ─────────────────
|
||||
|
||||
test('GEHEGE live: /becken leitet auf /gehege weiter', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/becken')
|
||||
await expect(page).toHaveURL(/\/gehege$/, { timeout: 5_000 })
|
||||
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('GEHEGE live: "/" leitet auf /rennmaeuse weiter (kein Start-Screen)', async ({ page }) => {
|
||||
skipUnlessLive()
|
||||
await page.goto('/')
|
||||
await expect(page).toHaveURL(/\/rennmaeuse$/, { timeout: 5_000 })
|
||||
await expect(
|
||||
page.getByRole('heading', { name: de.pages.gerbils.title, exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -43,16 +43,29 @@ export default function BreedingResultView({ result, title }: BreedingResultView
|
||||
) : (
|
||||
<>
|
||||
<ul className="farbschlag-cards">
|
||||
{result.byFarbschlag.map((f) => (
|
||||
<li key={f.farbschlag} className="farbschlag-card">
|
||||
<FarbschlagImage name={f.farbschlag} />
|
||||
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
||||
<span className="farbschlag-card__prob">
|
||||
{f.probability.percent}
|
||||
<small> ({f.probability.text})</small>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{result.byFarbschlag.map((f) => {
|
||||
// Representative genotype: highest-prob offspring for this farbschlag.
|
||||
const repGenotype = result.offspring.find(
|
||||
(o) => o.farbschlag === f.farbschlag,
|
||||
)?.genotype
|
||||
return (
|
||||
<li key={f.farbschlag} className="farbschlag-card">
|
||||
<div className="farbschlag-card__img">
|
||||
<FarbschlagImage name={f.farbschlag} />
|
||||
</div>
|
||||
<div className="farbschlag-card__body">
|
||||
<span className="farbschlag-card__name">{f.farbschlag}</span>
|
||||
{repGenotype && (
|
||||
<code className="farbschlag-card__geno">{repGenotype}</code>
|
||||
)}
|
||||
</div>
|
||||
<span className="farbschlag-card__prob">
|
||||
{f.probability.percent}
|
||||
<small> ({f.probability.text})</small>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
|
||||
@@ -179,17 +179,21 @@ describe('Farbschlag catalog', () => {
|
||||
expect(match.name).toBe('Unbekannter Farbschlag')
|
||||
})
|
||||
|
||||
it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
|
||||
// GEN-3f: 73 -> 61 (cchm CP reconciliation).
|
||||
// GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
|
||||
// giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
|
||||
expect(CATALOG_SIZE).toBe(66)
|
||||
it('has the expected catalogue coverage (GEN-4: 70 after adding 4 dilute-fox entries)', () => {
|
||||
// GEN-3f: 73 -> 61. GEN-3g: +5 -> 66. GEN-4: +4 (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs) -> 70.
|
||||
expect(CATALOG_SIZE).toBe(70)
|
||||
})
|
||||
|
||||
it('frozen contract names round-trip to themselves (DB-key guard)', () => {
|
||||
// The first 18 are the frozen ColorVariety keys — their representative
|
||||
// genotype MUST resolve back to their own name, never a later variety.
|
||||
// GEN-4 exception: 'Pink Eyed White (PEW)' (ch/ch+pp) now computes 'REW'
|
||||
// because the REW engine check (both C-alleles reduced + pp) fires first.
|
||||
// PEW stays in the catalog as a user-pickable import name; its computed
|
||||
// farbschlag is intentionally 'REW' per Julian's extended rule.
|
||||
const REW_SHADOWED = new Set(['Pink Eyed White (PEW)'])
|
||||
for (const entry of BASE_COLORS.slice(0, FROZEN_COUNT)) {
|
||||
if (REW_SHADOWED.has(entry.name)) continue
|
||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(entry.name)
|
||||
}
|
||||
})
|
||||
@@ -222,8 +226,9 @@ describe('Farbschlag catalog', () => {
|
||||
expect(CATALOG).toHaveLength(CATALOG_SIZE)
|
||||
expect(CATALOG[0]).toMatchObject({ name: 'Pink Eyed White (PEW)', sortOrder: 0 })
|
||||
// Every row has a non-empty canonical genotype display string and unique name.
|
||||
// GEN-3h: bracket notation (e[f], c[chm], c[h]) allowed in tokens.
|
||||
expect(new Set(CATALOG.map((c) => c.name)).size).toBe(CATALOG.length)
|
||||
expect(CATALOG.every((c) => /^[A-Za-z?]+( [A-Za-z?]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
expect(CATALOG.every((c) => /^[A-Za-z[\]?-]+( [A-Za-z[\]?-]+){7}$/.test(c.canonicalGenotype))).toBe(true)
|
||||
})
|
||||
|
||||
it('genotypeToFarbschlag (DATA-1 denormalization contract) returns the plain name', () => {
|
||||
@@ -372,9 +377,11 @@ describe("GEN-3c: unknown allele displays as '-' (stored as '?')", () => {
|
||||
})
|
||||
|
||||
describe('GEN-3c: no Unbekannt when the E locus is known (family fallback)', () => {
|
||||
it('eef with unknown other loci -> Fuchsschimmel (the reported bug case)', () => {
|
||||
it('eef with unknown other loci -> specific Schimmel variety (GEN-4: Fuchsschimmel is a category)', () => {
|
||||
// GEN-4: locusToken ef/e -> 'ef' enables catalog match; family fallback 'Fuchsschimmel' blocked.
|
||||
// aa + ef/e + C/D/G/P resolved via GEN-3d -> Kohlfuchsschimmel (A:a, C:C, D:D, E:ef, G:G, P:P).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
'Kohlfuchsschimmel',
|
||||
)
|
||||
})
|
||||
it('ee -> Fuchs family, efef -> a Schimmel (never Unbekannt) even with unknowns', () => {
|
||||
@@ -400,9 +407,11 @@ describe('GEN-3d: dominance tiebreak for unknown loci', () => {
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD EE GG PP sp- rere'))).toBe('Agouti')
|
||||
})
|
||||
|
||||
it('still: eef with unknowns -> Fuchsschimmel (family pin unaffected by tiebreak)', () => {
|
||||
it('still: eef with unknowns -> specific variety, not category (GEN-4 update)', () => {
|
||||
// GEN-4: 'Fuchsschimmel' is a Farbart/category; the engine now resolves to the
|
||||
// specific catalog entry (Kohlfuchsschimmel) via the locusToken ef/e -> 'ef' fix.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa C- D- eef Gg Pp spsp --'))).toBe(
|
||||
'Fuchsschimmel',
|
||||
'Kohlfuchsschimmel',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -420,7 +429,8 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
|
||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||
// GEN-4: dd base = 'Dilute Algierfuchs' → 'Dilute CP-Algierfuchs-Hell'
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||||
})
|
||||
|
||||
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
|
||||
@@ -470,8 +480,8 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
||||
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
|
||||
expect(name('AA cchmcchm DD ee GG PP spsp rere')).toBe('CP-Algierfuchs')
|
||||
expect(name('AA cchmcchm DD ee gg PP spsp rere')).toBe('CP-Polarfuchs')
|
||||
// dd agouti fox has no dedicated base -> the eFamily fallback yields 'CP-Fuchs'.
|
||||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('CP-Fuchs')
|
||||
// GEN-4: dd+ee base = 'Dilute Algierfuchs' -> 'Dilute CP-Algierfuchs' (no more CP-Fuchs catch-all).
|
||||
expect(name('AA cchmcchm dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs')
|
||||
// A- cchm efef -> CP-Orangeschimmel (Schimmel base under full C).
|
||||
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
|
||||
})
|
||||
@@ -480,11 +490,12 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
|
||||
const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
|
||||
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
|
||||
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
|
||||
// GEN-4: CP-Fuchs-Hell kept for import/hand-pick but engine now returns the specific name.
|
||||
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
|
||||
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
|
||||
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('Dilute CP-Algierfuchs-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -506,7 +517,8 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
|
||||
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
|
||||
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
|
||||
// GEN-4: dd+ee base = 'Dilute Algierfuchs' → prefix ordering: 'Dilute CP-Algierfuchs-Hell'
|
||||
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('Dilute CP-Algierfuchs-Hell')
|
||||
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
|
||||
})
|
||||
|
||||
@@ -523,3 +535,151 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
|
||||
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('dd entries use Dilute prefix — no more X-dd names', () => {
|
||||
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||
})
|
||||
|
||||
it('REW: both C alleles reduced (no full C) + pp = REW — all three cases (Julian confirmed)', () => {
|
||||
// hom cchm/cchm + pp
|
||||
expect(name('AA cchmcchm DD EE GG pp spsp rere')).toBe('REW') // CP-Gold
|
||||
expect(name('AA cchmcchm DD ee GG pp spsp rere')).toBe('REW') // CP-Goldfuchs
|
||||
expect(name('AA cchmcchm DD EE gg pp spsp rere')).toBe('REW') // CP-Elfenbein
|
||||
expect(name('AA cchmcchm DD ee gg pp spsp rere')).toBe('REW') // CP-Apricot
|
||||
expect(name('AA cchmcchm dd EE GG pp spsp rere')).toBe('REW') // CP-dd Gold
|
||||
// het cchm/ch + pp (Julian: also REW)
|
||||
expect(name('AA cchmch DD EE GG pp spsp rere')).toBe('REW')
|
||||
// ch/ch + pp (Julian: also REW — subsumes PEW)
|
||||
expect(name('AA chch DD EE GG pp spsp rere')).toBe('REW')
|
||||
expect(name('aa chch DD EE GG pp spsp rere')).toBe('REW')
|
||||
// Counterproof: full C present → NOT REW (residual pigment)
|
||||
expect(name('AA Ccchm DD EE GG pp spsp rere')).not.toBe('REW') // Cc[chm] + pp = Gold-like
|
||||
expect(name('AA Cch DD EE GG pp spsp rere')).not.toBe('REW') // Cc[h] + pp
|
||||
})
|
||||
|
||||
it('Farbarten (categories) never appear as computed results', () => {
|
||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||
const CATS = ['Standard', 'Colourpoint', 'Dilute', 'Fuchs', 'Fuchsschimmel', 'Schimmel', 'Colourpoint Dilute']
|
||||
for (const cat of CATS) {
|
||||
expect(BASE_COLORS.some(e => e.name === cat)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('bare Fuchs never appears — dilute-fox combinations are named specifically', () => {
|
||||
expect(name('AA CC dd ee GG PP spsp rere')).toBe('Dilute Algierfuchs')
|
||||
expect(name('AA CC dd ee GG pp spsp rere')).toBe('Dilute Goldfuchs')
|
||||
expect(name('aa CC dd ee GG pp spsp rere')).toBe('Dilute Rotfuchs')
|
||||
expect(name('AA CC dd ee gg PP spsp rere')).toBe('Dilute Polarfuchs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order', () => {
|
||||
// ── Display symbols ────────────────────────────────────────────────────
|
||||
it('ef displays as e[f], cchm as c[chm], ch as c[h]', () => {
|
||||
// Fuchsschimmel: E=[ef,ef] hom
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe(
|
||||
'AA CC DD e[f]e[f] GG PP spsp rere',
|
||||
)
|
||||
// C-locus het: cchm + ch
|
||||
expect(toDisplayString(fromDisplayString('aa cchmch DD EE GG PP spsp rere'))).toBe(
|
||||
'aa c[chm]c[h] DD EE GG PP spsp rere',
|
||||
)
|
||||
// C-locus hom cchm
|
||||
expect(toDisplayString(fromDisplayString('aa cchmcchm DD EE GG PP spsp rere'))).toBe(
|
||||
'aa c[chm]c[chm] DD EE GG PP spsp rere',
|
||||
)
|
||||
})
|
||||
|
||||
// ── E-locus display order: E > e > e[f] ─────────────────────────────
|
||||
it('Fuchsschimmel het pair {ef,e} displays as ee[f] (e before e[f])', () => {
|
||||
// Stored canonical: [ef, e] (ef dominant over e in storage).
|
||||
// Display must swap to [e, ef] per breeder convention.
|
||||
const g = fromDisplayString('aa CC DD eef Gg Pp spsp rere')
|
||||
expect(g.E).toEqual(['ef', 'e']) // storage order unchanged
|
||||
expect(toDisplayString(g)).toBe('aa CC DD ee[f] Gg Pp spsp rere')
|
||||
})
|
||||
|
||||
it('E+e stays Ee (E dominant over e, no swap needed)', () => {
|
||||
expect(toDisplayString(fromDisplayString('aa CC DD Ee GG PP spsp rere'))).toBe(
|
||||
'aa CC DD Ee GG PP spsp rere',
|
||||
)
|
||||
})
|
||||
|
||||
it('E+ef displays Ee[f] (E dominant stays first, ef renders as e[f])', () => {
|
||||
expect(toDisplayString(fromDisplayString('aa CC DD Eef GG PP spsp rere'))).toBe(
|
||||
'aa CC DD Ee[f] GG PP spsp rere',
|
||||
)
|
||||
})
|
||||
|
||||
// ── Julian oracle fixtures (HUMANQUESTION D3/D4) ─────────────────────
|
||||
it('Tier C: oracle display string round-trips exactly (aa C- D- ee[f] Gg Pp spsp rere)', () => {
|
||||
const display = 'aa C- D- ee[f] Gg Pp spsp rere'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.E).toEqual(['ef', 'e'])
|
||||
expect(g.C).toEqual(['C', '?'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
// Farbschlag scope is outside GEN-3h; god confirmed colour is correct as-is.
|
||||
})
|
||||
|
||||
it('Zuleika oracle: aa c[chm]c[h] DD Ee Gg PP spsp rere', () => {
|
||||
const display = 'aa c[chm]c[h] DD Ee Gg PP spsp rere'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['cchm', 'ch'])
|
||||
expect(g.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
})
|
||||
|
||||
it('Milka oracle: aa Cc[h] dd EE Gg P- Spsp rere', () => {
|
||||
const display = 'aa Cc[h] dd EE Gg P- Spsp rere'
|
||||
const g = fromDisplayString(display)
|
||||
expect(g.C).toEqual(['C', 'ch'])
|
||||
expect(g.D).toEqual(['d', 'd'])
|
||||
expect(toDisplayString(g)).toBe(display)
|
||||
})
|
||||
|
||||
// ── Parser accepts both forms ─────────────────────────────────────────
|
||||
it('bracket input round-trips identically to internal-symbol input', () => {
|
||||
expect(toDisplayString(fromDisplayString('AA c[chm]c[chm] DD EE GG PP spsp rere'))).toBe(
|
||||
toDisplayString(fromDisplayString('AA cchmcchm DD EE GG PP spsp rere')),
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD e[f]e[f] GG PP spsp rere'))).toBe(
|
||||
toDisplayString(fromDisplayString('AA CC DD efef GG PP spsp rere')),
|
||||
)
|
||||
expect(toDisplayString(fromDisplayString('AA CC DD ee[f] GG PP spsp rere'))).toBe(
|
||||
toDisplayString(fromDisplayString('AA CC DD eef GG PP spsp rere')),
|
||||
)
|
||||
})
|
||||
|
||||
it('e[-] standalone: parses as [e,?], displays e-', () => {
|
||||
const g = fromDisplayString('aa CC DD e[-] GG PP spsp rere')
|
||||
expect(g.E).toEqual(['e', '?'])
|
||||
expect(toDisplayString(g)).toBe('aa CC DD e- GG PP spsp rere')
|
||||
})
|
||||
|
||||
it('CR-1a: Silvain oracle ee[-] parses without crash → [e,?], displays e-', () => {
|
||||
// Real herdbook notation: ee[-] = fox allele e + unknown e-type second allele.
|
||||
// The lookbehind rule strips the second e[-] → '?', leaving 'e?' for splitToken.
|
||||
const input = 'aa c[chm]c[chm] Dd ee[-] Gg Pp Spsp'
|
||||
const g = fromDisplayString(input)
|
||||
expect(g.E).toEqual(['e', '?'])
|
||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||
expect(g.D).toEqual(['D', 'd'])
|
||||
expect(g.Sp).toEqual(['Sp', 'sp'])
|
||||
expect(toDisplayString(g)).toBe('aa c[chm]c[chm] Dd e- Gg Pp Spsp rere')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,8 +66,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
||||
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
||||
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
||||
{ name: 'dd Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
||||
{ name: 'dd Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
||||
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
||||
{ name: 'Dilute Platin', english: 'dd Lilac', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'platin-dd.jpg' },
|
||||
|
||||
// ── baseportal.de varieties (GEN-2), normalized to the frozen allele table ──
|
||||
{ name: 'Altweiss (REW)', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'altweiss-rew.jpeg' },
|
||||
@@ -78,16 +78,24 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||
// GEN-3a: efef base (otherwise wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||
{ name: 'Orangeschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
|
||||
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||
{ name: 'Agouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||
{ name: 'Silberagouti dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||
{ name: 'Kohlfuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||
{ name: 'Anthrazit dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||
{ name: 'Dilute Anthrazit', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'anthrazit-dd.jpg' },
|
||||
// GEN-4: dilute fox base entries — prevent bare 'Fuchs' family fallback for
|
||||
// agouti+dilute+fox combinations not otherwise covered in the catalog.
|
||||
{ name: 'Dilute Algierfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' } },
|
||||
{ name: 'Dilute Goldfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
||||
{ name: 'Dilute Rotfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'p' } },
|
||||
{ name: 'Dilute Polarfuchs', tokens: { A: 'A', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' } },
|
||||
// GEN-3a: efef gg base = Silberschimmel (breeder C5) — listed before the
|
||||
// A-specific Polarfuchsschimmel so the canonical efef-gg reverse-matches here.
|
||||
// No A restriction: both agouti (AA) and non-agouti (aa) ef/gg = Silberschimmel.
|
||||
{ name: 'Silberschimmel', tokens: { C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'silberschimmel.jpg' },
|
||||
{ name: 'Polarfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'g', P: 'P' }, image: 'polarfuchsschimmel.jpg' },
|
||||
{ name: 'Algierfuchsschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'algierfuchsschimmel.jpg' },
|
||||
@@ -104,8 +112,8 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Rotfuchs, hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs-hell.jpg' },
|
||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
|
||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||
@@ -147,10 +155,12 @@ export interface FarbschlagMatch {
|
||||
* Expressed token at a locus. GEN-3d: an UNKNOWN allele ('?') is resolved to the
|
||||
* MOST-DOMINANT allele of the locus (the safer default) rather than acting as a
|
||||
* match-anything wildcard — so an unknown-C animal reads as full-colour 'C', not
|
||||
* a c^h/c^chm colourpoint white. The E locus stays PAIR-aware so the Fuchs/
|
||||
* Schimmel family is distinguishable: ee->'e', e/ef->'eef', ef/ef->'ef'.
|
||||
* (The Fuchs/Schimmel FAMILY for unknown-E is still handled by eFamily on the
|
||||
* raw pair, which runs before this.)
|
||||
* a c^h/c^chm colourpoint white. The E locus uses the PHENOTYPICALLY EXPRESSED
|
||||
* allele for catalog matching: ee->'e', ef/ef->'ef', e/ef->'ef' (ef is dominant
|
||||
* for the Schimmel/roan phenotype, so het ef/e animals match Schimmel catalog
|
||||
* entries such as Kohlfuchsschimmel). GEN-4: 'eef' removed — 'Fuchsschimmel'
|
||||
* is a Farbart/category, not a concrete Farbschlag; the catalog must name the
|
||||
* variety specifically.
|
||||
*/
|
||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||
@@ -162,7 +172,9 @@ function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
if (locus === 'E') {
|
||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
|
||||
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
|
||||
// enables catalog entries like Kohlfuchsschimmel to match het animals).
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'ef'
|
||||
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
|
||||
}
|
||||
return dominantAllele(locus, x, y)
|
||||
@@ -175,10 +187,12 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
|
||||
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
|
||||
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
|
||||
* Returns null when E is dominant (full colour) or fully unknown.
|
||||
* E-locus family: used to scope the catalog search to E-aware entries.
|
||||
* Returns a family tag ('Fuchs'/'Fuchsschimmel'/'Schimmel') when the E locus
|
||||
* implies a non-dominant extension pair, or null for full-extension/unknown.
|
||||
* GEN-4: these family names are Farbarten (categories), NOT concrete Farbschläge.
|
||||
* They are ONLY used here as catalog-search filters; they must NEVER appear as
|
||||
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||
*/
|
||||
function eFamily(g: Genotype): string | null {
|
||||
const [x, y] = g.E
|
||||
@@ -205,7 +219,10 @@ function baseColourFor(g: Genotype): string | null {
|
||||
const base = family
|
||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||
return base?.name ?? family
|
||||
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
||||
// return null so farbschlagFor emits 'Unbekannter Farbschlag'.
|
||||
return base?.name ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,7 +250,13 @@ function colourpointName(g: Genotype): string | null {
|
||||
}
|
||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
|
||||
if (!base) return null
|
||||
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
||||
const DILUTE = 'Dilute '
|
||||
if (base.startsWith(DILUTE)) {
|
||||
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
||||
}
|
||||
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
||||
}
|
||||
|
||||
export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
@@ -241,8 +264,30 @@ export function farbschlagFor(g: Genotype): FarbschlagMatch {
|
||||
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
|
||||
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
|
||||
|
||||
// GEN-4 REW check (Julian confirmed + extended): both C alleles reduced (no full 'C')
|
||||
// AND pink-eyed (pp) = REW (Rotaugenweiß), A/D/E/G-independent.
|
||||
// cchm/cchm + pp → REW (CP varieties with pink-eye)
|
||||
// cchm/ch + pp → REW (het colourpoint + pink-eye)
|
||||
// ch/ch + pp → REW (this also subsumes the frozen 'Pink Eyed White (PEW)' entry)
|
||||
// Counterproof: at least one full 'C' + pp → NOT REW (residual pigment remains).
|
||||
const [c0, c1] = resolvedPair(g, 'C')
|
||||
const [p0, p1] = resolvedPair(g, 'P')
|
||||
const cReduced = (c: string) => c === 'cchm' || c === 'ch'
|
||||
if (cReduced(c0) && cReduced(c1) && p0 === 'p' && p1 === 'p') {
|
||||
const name = ['REW', ...modifiers].join(' ')
|
||||
return { name, base: null, unknown: false }
|
||||
}
|
||||
|
||||
const baseName = colourpointName(g) ?? baseColourFor(g)
|
||||
if (!baseName) {
|
||||
// GEN-4: safety guard — Farbarten (categories/families) are NEVER valid as
|
||||
// a computed Farbschlag output. If baseName is a category label, treat as
|
||||
// Unbekannt instead of leaking an invalid name into the UI.
|
||||
const CATEGORY_NAMES: ReadonlySet<string> = new Set([
|
||||
'Standard', 'Colourpoint', 'Dilute',
|
||||
'Fuchs', 'Fuchsschimmel', 'Schimmel',
|
||||
'Colourpoint Dilute',
|
||||
])
|
||||
if (!baseName || CATEGORY_NAMES.has(baseName)) {
|
||||
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
|
||||
}
|
||||
const name = [baseName, ...modifiers].join(' ')
|
||||
|
||||
@@ -2,35 +2,35 @@
|
||||
{
|
||||
"name": "Pink Eyed White (PEW)",
|
||||
"english": "Pink Eyed White",
|
||||
"canonicalGenotype": "AA chch DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG pp spsp rere",
|
||||
"sortOrder": 0,
|
||||
"image": "rotaugen-weiss-pew-d-sep-e-sep.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Hermelin",
|
||||
"english": "Dark Tailed White",
|
||||
"canonicalGenotype": "aa chch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[h]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 1,
|
||||
"image": "hermelin.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Himalaya",
|
||||
"english": "Himalayan",
|
||||
"canonicalGenotype": "AA chch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[h]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 2,
|
||||
"image": "himalaya.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Zobel",
|
||||
"english": "Sable",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE gg PP spsp rere",
|
||||
"sortOrder": 3,
|
||||
"image": "zobel.jpeg"
|
||||
},
|
||||
{
|
||||
"name": "Rotaugenschimmel",
|
||||
"english": "Red-Eyed Roan",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 4,
|
||||
"image": "rotaugen-schimmel.jpg"
|
||||
},
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Orangeschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 25,
|
||||
"image": "schimmel-orangeschimmel.jpg"
|
||||
},
|
||||
@@ -210,31 +210,31 @@
|
||||
},
|
||||
{
|
||||
"name": "Silberschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 32,
|
||||
"image": "silberschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 33,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 34,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 35,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] gg PP spsp rere",
|
||||
"sortOrder": 36,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -270,7 +270,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG pp spsp rere",
|
||||
"sortOrder": 42,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -282,7 +282,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 44,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -318,89 +318,89 @@
|
||||
},
|
||||
{
|
||||
"name": "Marder",
|
||||
"canonicalGenotype": "aa cchmcchm DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"sortOrder": 50,
|
||||
"image": "marder.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Siam",
|
||||
"canonicalGenotype": "aa cchmch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 51,
|
||||
"image": "siam-marder-hell.JPG"
|
||||
},
|
||||
{
|
||||
"name": "Zobel-Hell",
|
||||
"canonicalGenotype": "aa cchmch DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "aa c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"sortOrder": 52,
|
||||
"image": "zobel-hell.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE GG PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "agouti-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Agouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE GG PP spsp rere",
|
||||
"sortOrder": 54
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti",
|
||||
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD EE gg PP spsp rere",
|
||||
"sortOrder": 55,
|
||||
"image": "silberagouti-cp.JPG"
|
||||
},
|
||||
{
|
||||
"name": "CP-Silberagouti-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD EE gg PP spsp rere",
|
||||
"sortOrder": 56
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee GG PP spsp rere",
|
||||
"sortOrder": 57,
|
||||
"image": "algierfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Algierfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee GG PP spsp rere",
|
||||
"sortOrder": 58
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs",
|
||||
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD ee gg PP spsp rere",
|
||||
"sortOrder": 59,
|
||||
"image": "polarfuchs-cp.jpg"
|
||||
},
|
||||
{
|
||||
"name": "CP-Polarfuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD ee gg PP spsp rere",
|
||||
"sortOrder": 60
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee GG PP spsp rere",
|
||||
"sortOrder": 61
|
||||
},
|
||||
{
|
||||
"name": "CP-Fuchs-Hell",
|
||||
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] dd ee GG PP spsp rere",
|
||||
"sortOrder": 62
|
||||
},
|
||||
{
|
||||
"name": "CP-Blaufuchs",
|
||||
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] dd ee gg PP spsp rere",
|
||||
"sortOrder": 63
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel",
|
||||
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[chm] DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 64
|
||||
},
|
||||
{
|
||||
"name": "CP-Orangeschimmel-Hell",
|
||||
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA c[chm]c[h] DD e[f]e[f] GG PP spsp rere",
|
||||
"sortOrder": 65
|
||||
}
|
||||
]
|
||||
|
||||
@@ -74,6 +74,32 @@ export function wildType(): Genotype {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3h: breeder bracket-notation display symbols.
|
||||
* STORAGE symbols (ef / cchm / ch) are frozen; only the rendered form changes.
|
||||
*/
|
||||
const DISPLAY_SYMBOL: Readonly<Partial<Record<string, string>>> = {
|
||||
ef: 'e[f]',
|
||||
cchm: 'c[chm]',
|
||||
ch: 'c[h]',
|
||||
}
|
||||
function displaySymbol(allele: string): string {
|
||||
return DISPLAY_SYMBOL[allele] ?? allele
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-3h: E-locus display order — breeder convention is E > e > e[f].
|
||||
* Storage/dominance order is E > ef > e; display swaps ef and e so that
|
||||
* a Fuchsschimmel (E=[ef,e] stored) renders as "ee[f]" not "e[f]e".
|
||||
*/
|
||||
const E_DISPLAY_RANK: Readonly<Record<string, number>> = { E: 0, e: 1, ef: 2 }
|
||||
|
||||
function displayPair(locus: LocusKey, pair: AllelePair): AllelePair {
|
||||
if (locus !== 'E') return pair
|
||||
const rank = (x: string) => E_DISPLAY_RANK[x] ?? Number.MAX_SAFE_INTEGER
|
||||
return rank(pair[0]) <= rank(pair[1]) ? pair : [pair[1], pair[0]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere".
|
||||
* The Sls locus is OMITTED when wild-type (sl/sl) so legacy 8-locus strings and
|
||||
@@ -81,12 +107,18 @@ export function wildType(): Genotype {
|
||||
* (e.g. "… spsp rere Slsl"). Round-trips: a missing Sls re-parses to sl/sl.
|
||||
* GEN-3c: unknown alleles are STORED as '?' but DISPLAYED as '-' (breeder
|
||||
* convention) — e.g. ['C','?'] renders "C-".
|
||||
* GEN-3h: sub-alleles use breeder bracket notation (e[f], c[chm], c[h]);
|
||||
* E-locus display order is E > e > e[f] (e before e[f] in het pairs).
|
||||
*/
|
||||
export function toDisplayString(g: Genotype): string {
|
||||
return LOCUS_ORDER.filter(
|
||||
(locus) => locus !== 'Sls' || !(g.Sls[0] === 'sl' && g.Sls[1] === 'sl'),
|
||||
)
|
||||
.map((locus) => (g[locus][0] + g[locus][1]).replace(/\?/g, '-'))
|
||||
.map((locus) => {
|
||||
const [a, b] = displayPair(locus, g[locus])
|
||||
return displaySymbol(a) + displaySymbol(b)
|
||||
})
|
||||
.map((s) => s.replace(/\?/g, '-'))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
@@ -146,8 +178,19 @@ function normalizeToken(tok: string): string | null {
|
||||
if (t === 'WP') t = 'Slsl'
|
||||
t = t.replace(/S\(l\)/g, 'Sl').replace(/s\(l\)/g, 'sl')
|
||||
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
|
||||
// GEN-3c: '-' is the breeder's UNKNOWN marker on input; store internally as '?'
|
||||
// (the frozen storage contract keeps '?'; only DISPLAY renders '-').
|
||||
// GEN-3h: accept bracket display notation → canonical internal symbols.
|
||||
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
|
||||
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
|
||||
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
|
||||
// CR-1a: allele-prefixed bracket-unknown like ee[-] (Silvain).
|
||||
// When e[-] is PRECEDED by a letter it is the second unknown allele in a
|
||||
// 2-allele token (e.g. ee[-] → e + e[-] → e + ?). Lookbehind strips only
|
||||
// the e[-] part; the leading allele stays. Standalone e[-] falls through to
|
||||
// the generic [-]→? rule below (which makes the bracket-dash a wildcard,
|
||||
// leaving the leading allele intact for splitToken).
|
||||
t = t.replace(/(?<=[A-Za-z])e\[-\]/g, '?')
|
||||
t = t.replace(/\[-\]/g, '?') // bare/standalone bracket-unknown → wildcard
|
||||
// GEN-3c: plain dash is the breeder's UNKNOWN marker on input; store internally as '?'.
|
||||
t = t.replace(/-/g, '?')
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -539,28 +539,57 @@ textarea {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
/* GEN-4: wider min-width prevents long names (CP-Silberagouti-Hell) from overflowing */
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* GEN-4 layout fix: image left, body centre (name + genotype stacked), prob right.
|
||||
align-items: stretch so all three columns fill the card height consistently. */
|
||||
.farbschlag-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden; /* contain long genotype strings */
|
||||
}
|
||||
|
||||
.farbschlag-card__img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Body takes the middle space and allows wrapping for long names */
|
||||
.farbschlag-card__body {
|
||||
flex: 1;
|
||||
min-width: 0; /* allow flex child to shrink below content size */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.farbschlag-card__name {
|
||||
font-weight: 600;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Genotype code shown below the name — muted, small, wrappable */
|
||||
.farbschlag-card__geno {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #666);
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.farbschlag-card__prob {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-accent);
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* UX-MOBILE-2: scroll wrapper so genotype table scrolls horizontally on mobile
|
||||
|
||||
@@ -84,6 +84,34 @@
|
||||
"decision": "E-locus = ee[f] (Fuchs). This is the mother of animal 'C' (c-29042024) — un-quarantining her links C's second parent. Name in v.d. spelling (workaround from Re-Import #2); both spellings now match after FIX-1 (canon_pair identity).",
|
||||
"genotype": "Aa CC D- ee[f] Gg pp Spsp [DP]",
|
||||
"source": "Julian 2026-06-06 — HUMANQUESTION D4"
|
||||
},
|
||||
{
|
||||
"name": "Little Runner's Big Ben",
|
||||
"dob": "03.02.2020",
|
||||
"decision": "P-locus = Pp (not PP) — the two sources differed only at P",
|
||||
"genotype": "Aa Cc[chm] DD Ee Gg Pp Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Vance Jr. von den Kleinen Chaoten",
|
||||
"dob": "10.04.2022",
|
||||
"decision": "Sp-locus = spsp (kleines spsp, ungescheckt) — die Quellen unterschieden sich nur bei Sp (Spsp // spsp). C-Locus 'c[hm]' im Extrakt → als c[chm] normalisiert (kein gültiges Symbol; c[chm] = offensichtliche Absicht).",
|
||||
"genotype": "aa Cc[chm] Dd Ee gg P- spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Skarlett von den Kleinen Chaoten",
|
||||
"dob": "14.07.2013",
|
||||
"decision": "death date = 17.04.2016 (the '2018' variant was wrong — it had leaked as '/ +2018' into the genotype field; parse-leak already fixed in IMPORT-POLISH)",
|
||||
"dateOfDeath": "17.04.2016",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
},
|
||||
{
|
||||
"name": "Kazu von den Kleinen Chaoten",
|
||||
"dob": "23.04.2013",
|
||||
"decision": "E-locus = ee[f], G-locus = GG (one source wrote UwUw = GG in international notation), P-locus = PP — resolves the 3 contested loci",
|
||||
"genotype": "Aa Cc[chm] DD ee[f] GG PP Spsp",
|
||||
"source": "Julian 2026-06-07 — HUMANQUESTION D6"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user