Compare commits

..

6 Commits

Author SHA1 Message Date
ff42341c47 GEN-4 Addendum: 7 Farbarten-Kategorien blockiert + locusToken ef/e-Fix
catalog.ts:
- locusToken E: ef/e -> 'ef' (phenotypisch ausgedrücktes Allel; enables
  catalog-Match fuer het Schimmel-Tiere wie Kohlfuchsschimmel/Orangeschimmel).
  Vorher: 'eef' matchte keinen Katalogeintrag -> Family-Fallback 'Fuchsschimmel'.
- baseColourFor: Family-Fallback entfernt (null statt family-Name) — Familie
  ist nur noch Katalog-Suchfilter, nie direkter Ausgabewert.
- CATEGORY_NAMES Guard in farbschlagFor: Fuchs/Fuchsschimmel/Schimmel/Standard/
  Colourpoint/Dilute/Colourpoint Dilute -> Unbekannter Farbschlag wenn Engine
  diese Namen zurueckgeben wuerde.
- Orangeschimmel: A:'A' hinzugefuegt (Agouti-Einschraenkung), damit aa-ef-Tiere
  durch zu Kohlfuchsschimmel fallen.
- FK-Check: Keine der 7 Kategorie-Namen sind in BASE_COLORS -> 0 FK-Risiko.

genetics.test.ts:
- 'eef with unknown other loci': erwartet jetzt 'Kohlfuchsschimmel' (korrekt!)
- GEN-4 Farbarten-Fixture: Kategorie-Guard-Test + FK-Check-Assert.
Gate: build ✓  eslint ✓  vitest 96/96 ✓  e2e 148/148 ✓
2026-06-06 21:42:15 +02:00
5c327cd8d8 GEN-4 REW erweitert: alle 3 C-Kombi + pp = REW (Julian bestätigt)
- cReduced(c) = cchm || ch; REW wenn cReduced(c0) && cReduced(c1) && pp
  (cchm/cchm, cchm/ch, ch/ch alle REW; mind. ein volles C = NICHT REW)
- Counterproof-Test: C/cchm + pp und C/ch + pp = nicht REW
- Frozen-Round-Trip-Test: PEW (ch/ch+pp) explizit ausgenommen (intentional
  shadow durch REW-Check per Julian-Regel; PEW bleibt Dropdown-Name)
Gate: build ✓  eslint ✓  vitest 95/95 ✓  e2e 148/148 ✓
2026-06-06 21:32:20 +02:00
b12b5d720d GEN-4: Dilute-Präfix, REW-Erkennung, CP-Fuchs-Fix, BreedingResultView Gencode+Layout
Katalog (catalog.ts):
- 8 'X dd'/'dd X' Einträge → 'Dilute X' (Agouti/Silberagouti/Kohlfuchs/Anthrazit/
  Topas/Blaufuchs dd + dd Gold/Platin → Dilute Gold/Platin etc.)
- 4 neue Dilute-Fuchs-Basiseinträge (Dilute Algierfuchs/Goldfuchs/Rotfuchs/Polarfuchs)
  → verhindert naked 'Fuchs' Fallback für agouti+dilute+fox Genotypen. 66→70 Einträge.
- colourpointName: 'Dilute X' Base → 'Dilute CP-X' statt 'CP-Dilute X' (Präfix-Reihenfolge)
  → AA cchmcchm dd ee GG PP = 'Dilute CP-Algierfuchs' statt 'CP-Fuchs'.

Engine (farbschlagFor):
- REW-Check: c0==cchm && c1==cchm && p0==p && p1==p → 'REW' (Rotaugenweiß).
  Unabhängig von A/D/E/G. Flagged to god zur Bestätigung.

BreedingResultView:
- Gencode (bracket notation) pro Farbschlag-Karte (erster/wahrscheinlichster Genotyp).
- Layout-Fix: flexbox body (name+gencode gestapelt), min-width:0 gegen Overflow,
  word-break, grid min 15rem, prob flex-shrink:0 rechtsbündig.

Tests: GEN-4 describe (Dilute/REW/no-bare-Fuchs Fixtures); CATALOG_SIZE 66→70;
  CP-Fuchs/CP-Fuchs-Hell → Dilute CP-Algierfuchs(-Hell) in bestehenden Tests.
Gate: build ✓  eslint ✓  vitest 95/95 ✓  e2e 148/148 ✓
2026-06-06 21:27:32 +02:00
b7ebfd3057 Merge feature/namegen-backend (FEAT-NAMEGEN-BE): GET /names/suggest via Gemini + LitterLetter
Some checks failed
CI / Docker Build & Push (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CI / Backend Tests (.NET) (push) Successful in 57s
NameSuggestionService (bestehender OpenAiChatClient/Gemini, Fence-Strip, [{name,meaning,origin}]),
NamesEndpoints (503 NamesKeyMissing / 502 NamesUpstreamError), Litter.LitterLetter + Migration AddLitterLetter.
157/157, drift clean (Dwight). has-pending via CI (lokaler Build durch laufende App gesperrt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:16:40 +02:00
d268bbc122 D6: Kazu E=ee[f] G=GG P=PP (Julian 2026-06-07) → conflict-decisions.json + HUMANQUESTION
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 57s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m33s
CI / Docker Build & Push (push) Failing after 9s
Aa Cc[chm] DD ee[f] GG PP Spsp (UwUw-Quelle = GG). D6 nur noch Hanami offen (1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:59:29 +02:00
a638cf2c52 FEAT-NAMEGEN: GET /names/suggest backend (Gemini, 503 NamesKeyMissing, LitterLetter)
- NameSuggestionService: Gemini via existing OpenAiChatClient, builds
  letter/gender/usages/count prompt, strips markdown fences from response,
  deserialises [{name,meaning,origin}] array; NotConfigured -> 503.
- GET /names/suggest?letter=&gender=&usages=&count= -> Ok<List<NameSuggestion>>
  | 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}.
- Litter.LitterLetter (string?, nullable) + AddLitterLetter migration.
- 17 new tests (prompt assembly, fence strip, parse edge cases,
  503-not-configured, upstream-error); total 157/157 green.
- No Behind-the-Name dependency — Gemini path only (Julian's decision).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 20:56:33 +02:00
15 changed files with 2005 additions and 49 deletions

View 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));
}
}
}

View 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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -905,6 +905,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<string>("LitterLetter")
.HasColumnType("text");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");

View File

@@ -30,5 +30,9 @@ namespace GerbilManagerWebAPI.Models
/// 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; }
}
}

View 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);
}

View 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);
}

View File

@@ -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();

View File

@@ -128,7 +128,7 @@ Bitte je Tier sagen, **welcher Wert stimmt** (die Quellen widersprechen sich bei
| Hanami v.d. K.C. (*10.09.2015) | Sterbedatum: **12.12.2019****14.01.2020** (= D5) |
| ~~Little Runner's Big Ben (*03.02.2020)~~ | ✅ **Pp** (P-Locus) — Julian 2026-06-07 → in conflict-decisions.json |
| ~~Vance Jr. v.d. K.C. (*10.04.2022)~~ | ✅ **spsp** (ungescheckt) — Julian 2026-06-07 → in conflict-decisions.json |
| Kazu v.d. K.C. (*23.04.2013) | E-Locus: **e[f]e[f]****ee[f]** · G-Locus: **Gg****GG** · P-Locus: **P?****PP** |
| ~~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.)*

View File

@@ -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

View File

@@ -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)
}
})
@@ -373,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', () => {
@@ -401,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',
)
})
})
@@ -421,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)', () => {
@@ -471,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')
})
@@ -481,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')
})
})
@@ -507,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')
})
@@ -525,6 +536,58 @@ describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', ()
})
})
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]', () => {

View File

@@ -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(' ')

View File

@@ -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

View File

@@ -105,6 +105,13 @@
"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"
}
]
}