Compare commits

..

1 Commits

Author SHA1 Message Date
efce79b3fa GEN-3g: CP colourpoint het-naming rule — '-Hell' == cchm/ch (breeder C9 answer)
- colourpointName(): A- cchm/ch now yields 'CP-<base>-Hell' (was 'CP-<base>');
  A- cchm/cchm unchanged. Non-agouti branch (Marder/Siam/Zobel/Zobel-Hell)
  unchanged — already used the het token.
- Added 5 new catalog entries: CP-Agouti-Hell, CP-Silberagouti-Hell,
  CP-Algierfuchs-Hell, CP-Polarfuchs-Hell, CP-Orangeschimmel-Hell.
  CP-Fuchs-Hell was already present. Catalog 61 -> 66.
- Regenerated colorVarietySeed.generated.json (66 rows for Pam DATA-2 re-seed).
- Tests: 82 -> 82 (reworded het case) + 1 new GEN-3g describe block. All pass.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-06-06 15:14:23 +02:00
17 changed files with 129 additions and 334 deletions

View File

@@ -507,44 +507,6 @@ namespace GerbilManager.Tests
} }
} }
[Fact]
public async Task UndatedLitters_counted_as_WithoutDate_not_Created()
{
// COUNTER-BUG regression: litters with no parseable date must go into WithoutDate,
// NOT Created. On re-import, Created must be 0 (not 31-phantom-phantom-phantom...).
var dir = Path.Combine(Path.GetTempPath(), "undated-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
// One dated litter, one undated litter (blank date field)
File.WriteAllText(Path.Combine(dir, "litters.json"), """
[
{"id":"L-dated","litterId":"A","date":"01.02.2020","damName":"Mutter","sireName":"Vater","totalBorn":3,"zuchtnummer":"","note":""},
{"id":"L-undated","litterId":"B","date":"","damName":"Mutter","sireName":"Vater","totalBorn":0,"zuchtnummer":"","note":""}
]
""");
File.WriteAllText(Path.Combine(dir, "animals.json"), "[]");
using var db = NewDb();
// First run
var r1 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(2, r1.Litters.InSource);
Assert.Equal(1, r1.Litters.Created); // only the dated one
Assert.Equal(0, r1.Litters.AlreadyImported);
Assert.Equal(1, r1.Litters.WithoutDate); // the undated one
Assert.Equal(1, await db.Litters.CountAsync()); // only 1 persisted
// Second run (re-import): dated litter is now existing, undated still WithoutDate
var r2 = await new ImportService(db, dir, dir).RunAsync(execute: true);
Assert.Equal(0, r2.Litters.Created); // no phantom "created"
Assert.Equal(1, r2.Litters.AlreadyImported);
Assert.Equal(1, r2.Litters.WithoutDate);
Assert.Equal(1, await db.Litters.CountAsync()); // still only 1 row
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}
[Theory] [Theory]
[InlineData("01.02.2020", 2020, 2, 1)] [InlineData("01.02.2020", 2020, 2, 1)]
[InlineData("5.3.21", 2021, 3, 5)] [InlineData("5.3.21", 2021, 3, 5)]

View File

@@ -87,7 +87,7 @@ namespace GerbilManagerWebAPI.Import
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, public sealed record LitterSummary(int InSource, int Created, int AlreadyImported,
int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0, int DerivedFromChart = 0, int DerivedSkipped = 0, int ParentFksDropped = 0,
int ParentFksBackfilled = 0, int WithoutDate = 0); int ParentFksBackfilled = 0);
public sealed record AnimalSummary( public sealed record AnimalSummary(
int InSource, int InSource,

View File

@@ -94,17 +94,11 @@ namespace GerbilManagerWebAPI.Import
var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet(); var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet();
// ---- litters: create map source.id -> Litter (for high-confidence animal links) ---- // ---- litters: create map source.id -> Litter (for high-confidence animal links) ----
// COUNTER-BUG FIX: undated litters (31 in the Wurfchronik) have no parseable date, int littersCreated = 0, littersExisting = 0;
// 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.
int littersCreated = 0, littersExisting = 0, littersWithoutDate = 0;
var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id var litterIdMap = new Dictionary<string, Guid>(); // source litter id -> Litter.Id
foreach (var sl in litters) foreach (var sl in litters)
{ {
var date = ParseDate(sl.Date); var date = ParseDate(sl.Date);
if (date is null) { littersWithoutDate++; continue; } // undated: skip entirely
var name = $"Wurf {sl.LitterId}".Trim(); var name = $"Wurf {sl.LitterId}".Trim();
var key = $"{name}|{date:yyyy-MM-dd}"; var key = $"{name}|{date:yyyy-MM-dd}";
if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; } if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; }
@@ -112,19 +106,19 @@ namespace GerbilManagerWebAPI.Import
var id = Guid.NewGuid(); var id = Guid.NewGuid();
litterIdMap[sl.Id] = id; litterIdMap[sl.Id] = id;
littersCreated++; littersCreated++;
if (execute) if (execute && date is DateOnly d)
{ {
_db.Litters.Add(new Litter _db.Litters.Add(new Litter
{ {
Id = id, Id = id,
Name = name, Name = name,
Date = date.Value, Date = d,
TotalBorn = sl.TotalBorn, TotalBorn = sl.TotalBorn,
Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note, Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note,
PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer, PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer,
}); });
} }
if (samples.Count < 8) if (samples.Count < 8 && date is not null)
samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}"); samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}");
} }
if (execute) await _db.SaveChangesAsync(); if (execute) await _db.SaveChangesAsync();
@@ -460,8 +454,6 @@ namespace GerbilManagerWebAPI.Import
if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync(); if (execute && parentFksBackfilled > 0) await _db.SaveChangesAsync();
} }
if (littersWithoutDate > 0)
notes.Add($"Würfe ohne Datum: {littersWithoutDate} Wurfchronik-Einträge ohne parsbares Geburtsdatum übersprungen (weder erstellt noch verknüpft).");
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0) if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe)."); notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
@@ -476,7 +468,7 @@ namespace GerbilManagerWebAPI.Import
return new ImportReport( return new ImportReport(
Executed: execute, Executed: execute,
Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled, littersWithoutDate), Litters: new LitterSummary(litters.Count, littersCreated, littersExisting, derivedLitters, derivedLittersSkipped, litterParentFksDropped, parentFksBackfilled),
Animals: new AnimalSummary( Animals: new AnimalSummary(
animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting,
new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs), new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs),

View File

@@ -3,7 +3,7 @@
* AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis). * AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis).
* Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock. * Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock.
*/ */
import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures' import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const ta = de.pages.anfragen const ta = de.pages.anfragen
const td = ta.detail const td = ta.detail
@@ -23,8 +23,6 @@ test.describe('Anfragen', () => {
// Status-Badge auf der Karte // Status-Badge auf der Karte
await expect(cards.nth(0)).toContainText(ta.statusLabels.New) await expect(cards.nth(0)).toContainText(ta.statusLabels.New)
// UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Filter: nur Beantwortet // Filter: nur Beantwortet
await page.getByLabel(td.statusLabel).selectOption('Answered') await page.getByLabel(td.statusLabel).selectOption('Answered')
await expect(cards).toHaveCount(1) await expect(cards).toHaveCount(1)

View File

@@ -1,5 +1,5 @@
/** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */ /** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures' import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils const t = de.pages.gerbils
@@ -14,13 +14,11 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) =
await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0) await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0)
}) })
test('Toggle „Externe Ahnen einblenden zeigt externe Tiere mit Extern-Markierung', async ({ page }) => { test('Toggle „Externe Ahnen einblenden zeigt externe Tiere mit Extern-Markierung', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
await gotoSection(page, de.nav.gerbils) await gotoSection(page, de.nav.gerbils)
await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible() await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible()
// UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
await page.getByRole('checkbox', { name: t.filters.showExternal }).check() await page.getByRole('checkbox', { name: t.filters.showExternal }).check()
const maxRow = page.locator('.gerbil-row', { hasText: 'Max' }) const maxRow = page.locator('.gerbil-row', { hasText: 'Max' })

View File

@@ -1,85 +0,0 @@
/**
* UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop.
*
* Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst.
* Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.gerbils
test.describe('FilterPanel Rennmäuse-Liste', () => {
test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Toggle-Button sichtbar.
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeVisible()
// Status-Beschriftung im Drawer ist noch verborgen.
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Öffnen → Status-Feld wird sichtbar.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
// Schließen → wieder verborgen.
await toggle.click()
await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible()
})
test('Phone: Badge zählt aktive Filter korrekt', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'phone') return
await gotoSection(page, de.nav.gerbils)
const toggle = page.locator('.filter-panel__toggle')
// Initial: Status='Active' ist Default → Badge zeigt kein „(N)".
await expect(toggle).toHaveText('Filter')
// Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter.
await toggle.click()
await page.locator('.filter-panel__drawer select').nth(1).selectOption('male')
await expect(toggle).toHaveText('Filter (1)')
// Zurücksetzen → Badge weg.
await page.getByRole('button', { name: de.filterPanel.resetButton }).click()
await expect(toggle).toHaveText('Filter')
})
test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({
page,
}, testInfo) => {
skipUnlessMock()
if (testInfo.project.name !== 'desktop') return
await gotoSection(page, de.nav.gerbils)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
// Kein Toggle-Button auf Desktop (display:none via Media Query).
const toggle = page.locator('.filter-panel__toggle')
await expect(toggle).toBeHidden()
// Status-Beschriftung direkt sichtbar.
await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible()
})
})

View File

@@ -63,18 +63,6 @@ export function acceptNextDialog(page: Page) {
page.once('dialog', (d) => void d.accept()) page.once('dialog', (d) => void d.accept())
} }
/**
* UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist
* (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann
* kein Klick nötig — Controls sind direkt sichtbar.
*/
export async function openFilterPanel(page: Page) {
const toggle = page.locator('.filter-panel__toggle')
if (await toggle.isVisible()) {
await toggle.click()
}
}
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */ /** Eindeutiger Name für LIVE-taugliche Create-Flows. */
export const uniqueName = (prefix: string) => export const uniqueName = (prefix: string) =>
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` `${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`

View File

@@ -1,5 +1,5 @@
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */ /** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures' import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
const t = de.pages.gerbils const t = de.pages.gerbils
const tabs = de.pages.tierTabs const tabs = de.pages.tierTabs
@@ -23,8 +23,6 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
// UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen.
await openFilterPanel(page)
// Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel). // Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel).
await page await page
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) }) .locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })

View File

@@ -1,52 +0,0 @@
import { useState, type ReactNode } from 'react'
import { de } from '../strings/de'
import './filterPanel.css'
interface FilterPanelProps {
/** Always visible on mobile (typically the search text input). Optional. */
searchField?: ReactNode
/** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */
children: ReactNode
/** Number of currently active (non-default) filter values. Shown as badge. */
activeCount: number
/** Called when the reset button is clicked. */
onReset: () => void
}
/**
* UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile.
* Render inside the existing `.filters` div (or replace it entirely).
*
* Desktop (>=768px): renders all children inline, identical to today.
* Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals
* the rest of the controls in a column drawer + a reset button.
*/
export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) {
const [open, setOpen] = useState(false)
const t = de.filterPanel
const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton
return (
<div className={`filters filter-panel${open ? ' filter-panel--open' : ''}`}>
{searchField}
<button
type="button"
className="btn btn--ghost filter-panel__toggle"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={label}
>
{label}
</button>
<div className="filter-panel__drawer">
{children}
{activeCount > 0 && (
<button type="button" className="btn btn--ghost filter-panel__reset-btn" onClick={onReset}>
{t.resetButton}
</button>
)}
</div>
</div>
)
}

View File

@@ -1,53 +0,0 @@
/*
* UX-MOBILE-1: FilterPanel — collapsible filter drawer on mobile.
*
* Desktop (>=768px): toggle hidden, drawer shows as display:contents so its
* children participate directly in the parent .filters flex row.
* Mobile (<768px): searchField inline, then toggle button. Tap opens a full-
* width drawer (flex column) with the remaining filters + reset button.
*/
/* ── Mobile default ── */
.filter-panel__toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.filter-panel__drawer {
display: none;
flex-direction: column;
gap: 0.75rem;
width: 100%;
padding-top: 0.25rem;
}
.filter-panel--open .filter-panel__drawer {
display: flex;
}
/* ── Desktop ── */
@media (min-width: 768px) {
.filter-panel__toggle {
display: none;
}
.filter-panel__drawer {
/* Let children participate directly in the parent flex row. */
display: contents;
}
/* Reset button sits in the flex row on desktop when active filters exist. */
.filter-panel__reset-btn {
align-self: flex-end;
}
}
/* ── Mobile reset button ── */
@media (max-width: 767px) {
.filter-panel__reset-btn {
align-self: flex-start;
margin-top: 0.25rem;
}
}

View File

@@ -179,10 +179,11 @@ describe('Farbschlag catalog', () => {
expect(match.name).toBe('Unbekannter Farbschlag') expect(match.name).toBe('Unbekannter Farbschlag')
}) })
it('has the expected catalogue coverage (GEN-3f: 61 after cchm CP reconciliation)', () => { it('has the expected catalogue coverage (GEN-3g: 66 after adding CP-*-Hell het variants)', () => {
// GEN-3f collapsed the 24 portal cchm colourpoint rows to 12 breeder-named // GEN-3f: 73 -> 61 (cchm CP reconciliation).
// varieties (Marder/Siam/Zobel/Zobel-Hell + CP-<base>), so 73 -> 61. // GEN-3g: +5 het variants (CP-Agouti/Silberagouti/Algierfuchs/Polarfuchs/Orangeschimmel -Hell),
expect(CATALOG_SIZE).toBe(61) // giving 61 + 5 = 66. CP-Fuchs-Hell was already counted.
expect(CATALOG_SIZE).toBe(66)
}) })
it('frozen contract names round-trip to themselves (DB-key guard)', () => { it('frozen contract names round-trip to themselves (DB-key guard)', () => {
@@ -414,8 +415,12 @@ describe('GEN-3e: C-locus colourpoint naming', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti') expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
}) })
it('A- + cchm/ch -> CP-<base colour>', () => { it('A- + cchm/ch -> CP-<base colour>-Hell (GEN-3g: het gets -Hell suffix)', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti') expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
}) })
it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => { it('aa fixed colourpoint names (Marder/Siam/Zobel/Zobel-Hell)', () => {
@@ -471,10 +476,50 @@ describe('GEN-3f: CP catalog reconciled to the breeder CP- naming (matches her l
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel') expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
}) })
it('het cchm/ch colourpoints (Siam, Zobel-Hell) resolve to their own names', () => { it('het cchm/ch colourpoints (Siam, Zobel-Hell, CP-*-Hell) resolve to their own names', () => {
const siam = BASE_COLORS.find((e) => e.name === 'Siam')! const siam = BASE_COLORS.find((e) => e.name === 'Siam')!
const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')! const zh = BASE_COLORS.find((e) => e.name === 'Zobel-Hell')!
const cpah = BASE_COLORS.find((e) => e.name === 'CP-Agouti-Hell')!
const cpfh = BASE_COLORS.find((e) => e.name === 'CP-Fuchs-Hell')!
expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam') expect(genotypeToFarbschlag(representativeGenotype(siam))).toBe('Siam')
expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell') expect(genotypeToFarbschlag(representativeGenotype(zh))).toBe('Zobel-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpah))).toBe('CP-Agouti-Hell')
expect(genotypeToFarbschlag(representativeGenotype(cpfh))).toBe('CP-Fuchs-Hell')
})
})
describe('GEN-3g: "-Hell" in variety name == cchm/ch het; hom == cchm/cchm', () => {
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
const has = (n: string) => BASE_COLORS.some((e) => e.name === n)
it('all new -Hell het entries exist in catalog', () => {
for (const n of [
'CP-Agouti-Hell', 'CP-Silberagouti-Hell', 'CP-Algierfuchs-Hell',
'CP-Polarfuchs-Hell', 'CP-Orangeschimmel-Hell',
]) {
expect(has(n)).toBe(true)
}
})
it('engine correctly maps het (cchm/ch) to -Hell suffix for all A- bases', () => {
expect(name('AA cchmch DD EE GG PP spsp rere')).toBe('CP-Agouti-Hell')
expect(name('AA cchmch DD EE gg PP spsp rere')).toBe('CP-Silberagouti-Hell')
expect(name('AA cchmch DD ee GG PP spsp rere')).toBe('CP-Algierfuchs-Hell')
expect(name('AA cchmch DD ee gg PP spsp rere')).toBe('CP-Polarfuchs-Hell')
expect(name('AA cchmch dd ee GG PP spsp rere')).toBe('CP-Fuchs-Hell')
expect(name('AA cchmch DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel-Hell')
})
it('hom (cchm/cchm) still maps without -Hell suffix', () => {
expect(name('AA cchmcchm DD EE GG PP spsp rere')).toBe('CP-Agouti')
expect(name('AA cchmcchm DD EE gg PP spsp rere')).toBe('CP-Silberagouti')
expect(name('AA cchmcchm DD efef GG PP spsp rere')).toBe('CP-Orangeschimmel')
})
it('aa non-agouti branch is unchanged (Marder/Siam/Zobel/Zobel-Hell)', () => {
expect(name('aa cchmcchm DD EE GG PP spsp rere')).toBe('Marder')
expect(name('aa cchmch DD EE GG PP spsp rere')).toBe('Siam')
expect(name('aa cchmcchm DD EE gg PP spsp rere')).toBe('Zobel')
expect(name('aa cchmch DD EE gg PP spsp rere')).toBe('Zobel-Hell')
}) })
}) })

View File

@@ -107,27 +107,29 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
{ name: 'Topas dd', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.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: 'Blaufuchs dd', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
// ── GEN-3f: c^chm colourpoint varieties, reconciled to the breeder's CP- naming ── // ── GEN-3f/3g: c^chm colourpoint varieties ──
// The merged GEN-3e colourpointName() rule is authoritative: aa points are the // GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
// marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell — E and D irrelevant); // GEN-3g (breeder rule): '-Hell' == cchm/ch het; no '-Hell' == cchm/cchm hom.
// A- points take the 'CP-<base colour>' prefix and the '-Hell' shade variants // A- points: hom -> 'CP-<base>', het -> 'CP-<base>-Hell' (colourpointName()).
// collapse (other loci irrelevant for the CP prefix). These names == the strings // CP-Fuchs is a Sammelbegriff (unknown loci); its -Hell het = CP-Fuchs-Hell.
// in her live data (god: extract animals.json) so the re-import name-matches and // CP-Blaufuchs (D:d, G:g) still resolves engine-side to 'CP-Fuchs' (dd/gg
// the Farbschlag mismatch hint stops. The het cchm/ch points (Siam, Zobel-Hell, // fox CP has no dedicated base entry); kept for import name-match + hand-pick.
// CP-Fuchs-Hell) use the 'cchm/ch' pair token. The agouti fox/dilute points
// (CP-Fuchs/CP-Blaufuchs) resolve through the engine's E-family fallback to
// 'CP-Fuchs'; their distinct dropdown names remain for hand-pick + import match.
{ name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' }, { name: 'Marder', tokens: { A: 'a', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'marder.JPG' },
{ name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' }, { name: 'Siam', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'siam-marder-hell.JPG' },
{ name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' }, { name: 'Zobel-Hell', tokens: { A: 'a', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'zobel-hell.jpg' },
{ name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' }, { name: 'CP-Agouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'G', P: 'P' }, image: 'agouti-cp.jpg' },
{ name: 'CP-Agouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'G', P: 'P' } },
{ name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' }, { name: 'CP-Silberagouti', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-cp.JPG' },
{ name: 'CP-Silberagouti-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'E', G: 'g', P: 'P' } },
{ name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' }, { name: 'CP-Algierfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-cp.jpg' },
{ name: 'CP-Algierfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' }, { name: 'CP-Polarfuchs', tokens: { A: 'A', C: 'cchm', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs-cp.jpg' },
{ name: 'CP-Polarfuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'D', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } }, { name: 'CP-Fuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } }, { name: 'CP-Fuchs-Hell', tokens: { A: 'A', C: 'cchm/ch', D: 'd', E: 'e', G: 'G', P: 'P' } },
{ name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } }, { name: 'CP-Blaufuchs', tokens: { A: 'A', C: 'cchm', D: 'd', E: 'e', G: 'g', P: 'P' } },
{ name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } }, { name: 'CP-Orangeschimmel', tokens: { C: 'cchm', D: 'D', E: 'ef', G: 'G', P: 'P' } },
{ name: 'CP-Orangeschimmel-Hell', tokens: { C: 'cchm/ch', D: 'D', E: 'ef', G: 'G', P: 'P' } },
] ]
export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag' export const UNKNOWN_FARBSCHLAG = 'Unbekannter Farbschlag'
@@ -207,12 +209,14 @@ function baseColourFor(g: Genotype): string | null {
} }
/** /**
* GEN-3e: the C-locus colourpoint NAMING transform (breeder-authoritative). * GEN-3e/3g: the C-locus colourpoint NAMING transform (breeder-authoritative).
* Returns the colourpoint name, or null when it doesn't apply (full C present, * Returns the colourpoint name, or null when it doesn't apply (full C present,
* or chch — which the base matcher names Hermelin/Himalaya, preserving both). * or chch — which the base matcher names Hermelin/Himalaya, preserving both).
* aa cchm/cchm -> Marder | aa cchm/ch -> Siam * aa cchm/cchm -> Marder | aa cchm/ch -> Siam
* aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell * aa cchm/cchm gg -> Zobel | aa cchm/ch gg -> Zobel-Hell
* A- cchm/cchm | cchm/ch -> CP-<base colour> (base computed as if C were full) * A- cchm/cchm -> CP-<base> | A- cchm/ch -> CP-<base>-Hell
* GEN-3g (breeder rule): "-Hell" in variety name == c[h]-Allel (cchm/ch het);
* no "-Hell" == cchm/cchm hom. CP-Fuchs is a Sammelbegriff (unknown loci).
*/ */
function colourpointName(g: Genotype): string | null { function colourpointName(g: Genotype): string | null {
const c = resolvedPair(g, 'C') const c = resolvedPair(g, 'C')
@@ -227,9 +231,9 @@ function colourpointName(g: Genotype): string | null {
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell' if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
return bothCchm ? 'Marder' : 'Siam' return bothCchm ? 'Marder' : 'Siam'
} }
// A- colourpoint -> CP-<base colour>, base as if C were full. // A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] })) const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
return base ? `CP-${base}` : null return base ? `CP-${base}${bothCchm ? '' : '-Hell'}` : null
} }
export function farbschlagFor(g: Genotype): FarbschlagMatch { export function farbschlagFor(g: Genotype): FarbschlagMatch {

View File

@@ -340,42 +340,67 @@
"sortOrder": 53, "sortOrder": 53,
"image": "agouti-cp.jpg" "image": "agouti-cp.jpg"
}, },
{
"name": "CP-Agouti-Hell",
"canonicalGenotype": "AA cchmch DD EE GG PP spsp rere",
"sortOrder": 54
},
{ {
"name": "CP-Silberagouti", "name": "CP-Silberagouti",
"canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere", "canonicalGenotype": "AA cchmcchm DD EE gg PP spsp rere",
"sortOrder": 54, "sortOrder": 55,
"image": "silberagouti-cp.JPG" "image": "silberagouti-cp.JPG"
}, },
{
"name": "CP-Silberagouti-Hell",
"canonicalGenotype": "AA cchmch DD EE gg PP spsp rere",
"sortOrder": 56
},
{ {
"name": "CP-Algierfuchs", "name": "CP-Algierfuchs",
"canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere", "canonicalGenotype": "AA cchmcchm DD ee GG PP spsp rere",
"sortOrder": 55, "sortOrder": 57,
"image": "algierfuchs-cp.jpg" "image": "algierfuchs-cp.jpg"
}, },
{
"name": "CP-Algierfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee GG PP spsp rere",
"sortOrder": 58
},
{ {
"name": "CP-Polarfuchs", "name": "CP-Polarfuchs",
"canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere", "canonicalGenotype": "AA cchmcchm DD ee gg PP spsp rere",
"sortOrder": 56, "sortOrder": 59,
"image": "polarfuchs-cp.jpg" "image": "polarfuchs-cp.jpg"
}, },
{
"name": "CP-Polarfuchs-Hell",
"canonicalGenotype": "AA cchmch DD ee gg PP spsp rere",
"sortOrder": 60
},
{ {
"name": "CP-Fuchs", "name": "CP-Fuchs",
"canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere", "canonicalGenotype": "AA cchmcchm dd ee GG PP spsp rere",
"sortOrder": 57 "sortOrder": 61
}, },
{ {
"name": "CP-Fuchs-Hell", "name": "CP-Fuchs-Hell",
"canonicalGenotype": "AA cchmch dd ee GG PP spsp rere", "canonicalGenotype": "AA cchmch dd ee GG PP spsp rere",
"sortOrder": 58 "sortOrder": 62
}, },
{ {
"name": "CP-Blaufuchs", "name": "CP-Blaufuchs",
"canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere", "canonicalGenotype": "AA cchmcchm dd ee gg PP spsp rere",
"sortOrder": 59 "sortOrder": 63
}, },
{ {
"name": "CP-Orangeschimmel", "name": "CP-Orangeschimmel",
"canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere", "canonicalGenotype": "AA cchmcchm DD efef GG PP spsp rere",
"sortOrder": 60 "sortOrder": 64
},
{
"name": "CP-Orangeschimmel-Hell",
"canonicalGenotype": "AA cchmch DD efef GG PP spsp rere",
"sortOrder": 65
} }
] ]

View File

@@ -15,7 +15,6 @@ import {
} from '../api/requests' } from '../api/requests'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDateTime } from '../format/labels' import { formatDateTime } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './anfragen.css' import './anfragen.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -76,10 +75,7 @@ export default function AnfragenPage() {
{sync.error && <div className="alert alert--error">{sync.error}</div>} {sync.error && <div className="alert alert--error">{sync.error}</div>}
{/* Status-Filter */} {/* Status-Filter */}
<FilterPanel <div className="filters">
activeCount={status !== '' ? 1 : 0}
onReset={() => { setStatus(''); setPage(1) }}
>
<label className="field"> <label className="field">
<span>{t.detail.statusLabel}</span> <span>{t.detail.statusLabel}</span>
<select <select
@@ -97,7 +93,7 @@ export default function AnfragenPage() {
))} ))}
</select> </select>
</label> </label>
</FilterPanel> </div>
{requests.loading && <p className="muted">{de.common.loading}</p>} {requests.loading && <p className="muted">{de.common.loading}</p>}
{requests.error && ( {requests.error && (

View File

@@ -7,7 +7,6 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel, statusLabel } from '../format/labels' import { formatDate, genderLabel, statusLabel } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
import './gerbils.css' import './gerbils.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -83,14 +82,6 @@ export default function GerbilsPage() {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const items = gerbils.data?.items ?? [] const items = gerbils.data?.items ?? []
// UX-MOBILE-1: count non-default filter values for the badge.
const activeFilterCount =
(status !== 'Active' ? 1 : 0) +
(gender !== '' ? 1 : 0) +
(colorVarietyId !== '' ? 1 : 0) +
(originBreeder !== '' ? 1 : 0) +
(showExternal ? 1 : 0)
// Multi-select bulk "Zur Abgabe stellen". // Multi-select bulk "Zur Abgabe stellen".
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const toggleSelect = (id: string) => const toggleSelect = (id: string) =>
@@ -127,20 +118,15 @@ export default function GerbilsPage() {
</Link> </Link>
</header> </header>
<FilterPanel <div className="filters">
searchField={ <input
<input type="search"
type="search" className="input"
className="input" placeholder={t.searchPlaceholder}
placeholder={t.searchPlaceholder} value={search}
value={search} onChange={(e) => onFilterChange(setSearch)(e.target.value)}
onChange={(e) => onFilterChange(setSearch)(e.target.value)} aria-label={t.fields.name}
aria-label={t.fields.name} />
/>
}
activeCount={activeFilterCount}
onReset={resetFilters}
>
<label className="field"> <label className="field">
<span>{t.filters.status}</span> <span>{t.filters.status}</span>
<select <select
@@ -214,7 +200,10 @@ export default function GerbilsPage() {
onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)} onChange={(e) => onFilterChange(setShowExternal)(e.target.checked)}
/> />
</label> </label>
</FilterPanel> <button type="button" className="btn" onClick={resetFilters}>
{t.filters.reset}
</button>
</div>
{gerbils.loading && <p className="muted">{de.common.loading}</p>} {gerbils.loading && <p className="muted">{de.common.loading}</p>}
{gerbils.error && ( {gerbils.error && (

View File

@@ -7,7 +7,6 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify'
import type { Litter } from '../api/types' import type { Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate } from '../format/labels' import { formatDate } from '../format/labels'
import { FilterPanel } from '../components/FilterPanel'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -112,10 +111,7 @@ export default function WuerfeListPage() {
{tab === 'litters' && ( {tab === 'litters' && (
<> <>
<FilterPanel <div className="filters">
activeCount={year !== '' ? 1 : 0}
onReset={() => { setYear(''); setSort('dateDesc'); setPage(1) }}
>
<label className="field"> <label className="field">
<span>{t.filterYear}</span> <span>{t.filterYear}</span>
<select <select
@@ -140,7 +136,7 @@ export default function WuerfeListPage() {
<option value="dateAsc">{t.sort.dateAsc}</option> <option value="dateAsc">{t.sort.dateAsc}</option>
</select> </select>
</label> </label>
</FilterPanel> </div>
{litters.loading && <p className="muted">{de.common.loading}</p>} {litters.loading && <p className="muted">{de.common.loading}</p>}
{litters.error && ( {litters.error && (

View File

@@ -807,12 +807,6 @@ export const de = {
{ key: 'schreckhaft', label: 'schreckhaft' }, { key: 'schreckhaft', label: 'schreckhaft' },
], ],
}, },
// ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ──
filterPanel: {
toggleButton: 'Filter',
resetButton: 'Filter zurücksetzen',
closeButton: 'Schließen',
},
} as const } as const
export type Strings = typeof de export type Strings = typeof de