From 81f9d0ce2fda2610ea54f43b23ac0162e0ccf689 Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 19:57:54 +0200 Subject: [PATCH] FEAT-NAMEGEN UC-1: 'Name vorschlagen'-Panel auf GerbilFormPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/names.ts: suggestNames() + buildSuggestPath() + NAMEGEN_USAGES (norn/japa/mythg/ger/arb) - de.ts: namegen-Sektion (button, panelTitle, letter/usages/load/states) - NameSuggestPanel.tsx: inline-Panel mit Buchstabe/Herkunftskultur-Multiselect, Vorschlagsliste; 503 NamesKeyMissing → freundlicher Hinweis - GerbilFormPage: 'Name vorschlagen'-Button neben Namensfeld (htmlFor-Pattern), Panel schließt+befüllt bei Auswahl - vitest: 11 Tests (buildSuggestPath URL-Logik + NAMEGEN_USAGES-Struktur) - e2e: mock /names/suggest + namesConfigured-Flag; 4 Specs (Panel öffnen, Klick befüllt, Buchstabe-Filter, 503-Fallback) Gate: vitest 104/104, e2e 156/156, tsc clean --- gerbil-manager-web/e2e/mock-api.ts | 20 +++ gerbil-manager-web/e2e/mock-data.ts | 3 + gerbil-manager-web/e2e/namegen.spec.ts | 59 ++++++++ .../src/api/__tests__/names.test.ts | 73 ++++++++++ gerbil-manager-web/src/api/names.ts | 40 ++++++ .../src/components/NameSuggestPanel.css | 114 +++++++++++++++ .../src/components/NameSuggestPanel.tsx | 132 ++++++++++++++++++ .../src/pages/GerbilFormPage.tsx | 38 +++-- gerbil-manager-web/src/strings/de.ts | 13 ++ 9 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 gerbil-manager-web/e2e/namegen.spec.ts create mode 100644 gerbil-manager-web/src/api/__tests__/names.test.ts create mode 100644 gerbil-manager-web/src/api/names.ts create mode 100644 gerbil-manager-web/src/components/NameSuggestPanel.css create mode 100644 gerbil-manager-web/src/components/NameSuggestPanel.tsx diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 1e74a7a..c21efae 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -361,6 +361,26 @@ export async function installMockApi(page: Page): Promise { return json(route, 201, contract) } + // FEAT-NAMEGEN: /names/suggest + if (path === '/names/suggest' && method === 'GET') { + if (!db.namesConfigured) { + return json(route, 503, { code: 'NamesKeyMissing', message: 'Kein API-Key konfiguriert' }) + } + const letter = url.searchParams.get('letter')?.toUpperCase() + const allSuggestions = [ + { name: 'Fenrir', meaning: 'Wolf aus der Nordischen Mythologie', origin: 'Nordisch' }, + { name: 'Freya', meaning: 'Göttin der Liebe und Fruchtbarkeit', origin: 'Nordisch' }, + { name: 'Artemis', meaning: 'Göttin der Jagd und des Mondlichts', origin: 'Griech. Mythologie' }, + { name: 'Kira', meaning: 'Strahlendes Licht', origin: 'Japanisch' }, + { name: 'Luna', meaning: 'Mondgöttin', origin: 'Griech. Mythologie' }, + { name: 'Baldur', meaning: 'Gott des Lichts und der Reinheit', origin: 'Nordisch' }, + ] + const result = letter + ? allSuggestions.filter((s) => s.name.startsWith(letter)) + : allSuggestions + return json(route, 200, result) + } + // Generische Kollektionen: / und // m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) const col = m ? collections[m[1]] : undefined diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index ef9f19a..74544ef 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -74,6 +74,8 @@ export interface MockDb { // ABGABE: Verträge + KI-Inserat-Flag contracts: MockContract[] saleAdConfigured: boolean + // FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren + namesConfigured: boolean } function gerbil( @@ -292,5 +294,6 @@ export function seedDb(): MockDb { mailConfigured: true, contracts: [], saleAdConfigured: true, + namesConfigured: true, } } diff --git a/gerbil-manager-web/e2e/namegen.spec.ts b/gerbil-manager-web/e2e/namegen.spec.ts new file mode 100644 index 0000000..3f85762 --- /dev/null +++ b/gerbil-manager-web/e2e/namegen.spec.ts @@ -0,0 +1,59 @@ +/** FEAT-NAMEGEN: UC-1 — 'Name vorschlagen'-Panel auf der GerbilFormPage. */ +import { de, expect, skipUnlessMock, test } from './fixtures' + +const t = de.namegen +const tf = de.pages.gerbils.form + +test('Name-vorschlagen-Panel öffnet sich und zeigt Vorschläge (FEAT-NAMEGEN)', async ({ page }) => { + skipUnlessMock() + await page.goto('/rennmaeuse/neu') + await expect(page.getByRole('heading', { name: tf.createTitle })).toBeVisible() + + // Panel öffnen + await page.getByRole('button', { name: t.button }).click() + await expect(page.getByText(t.panelTitle)).toBeVisible() + + // Vorschläge laden + await page.getByRole('button', { name: t.loadButton }).click() + // Fenrir ist im Mock immer dabei (kein Buchstabe-Filter) + await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible() + await expect(page.getByText('Wolf aus der Nordischen Mythologie')).toBeVisible() +}) + +test('Klick auf Vorschlag befüllt Namensfeld und schließt Panel (FEAT-NAMEGEN)', async ({ page }) => { + skipUnlessMock() + await page.goto('/rennmaeuse/neu') + await page.getByRole('button', { name: t.button }).click() + await page.getByRole('button', { name: t.loadButton }).click() + await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible() + + // Klick auf Vorschlag 'Fenrir' + await page.getByRole('button', { name: 'Fenrir' }).click() + + // Panel geschlossen, Name-Feld befüllt + await expect(page.getByText(t.panelTitle)).toBeHidden() + await expect(page.getByLabel(`${de.pages.gerbils.fields.name} *`)).toHaveValue('Fenrir') +}) + +test('Buchstabe-Filter schränkt Vorschläge ein (FEAT-NAMEGEN)', async ({ page }) => { + skipUnlessMock() + await page.goto('/rennmaeuse/neu') + await page.getByRole('button', { name: t.button }).click() + await page.getByLabel(t.letterLabel).fill('F') + await page.getByRole('button', { name: t.loadButton }).click() + + // Mock gibt nur Namen mit F zurück: Fenrir + Freya + await expect(page.getByRole('button', { name: 'Fenrir' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Freya' })).toBeVisible() + // Artemis (A) nicht sichtbar + await expect(page.getByRole('button', { name: 'Artemis' })).toBeHidden() +}) + +test('503 NamesKeyMissing zeigt freundlichen Hinweis (FEAT-NAMEGEN)', async ({ page, mockDb }) => { + skipUnlessMock() + if (mockDb) mockDb.namesConfigured = false + await page.goto('/rennmaeuse/neu') + await page.getByRole('button', { name: t.button }).click() + await page.getByRole('button', { name: t.loadButton }).click() + await expect(page.getByText(t.keyMissing)).toBeVisible() +}) diff --git a/gerbil-manager-web/src/api/__tests__/names.test.ts b/gerbil-manager-web/src/api/__tests__/names.test.ts new file mode 100644 index 0000000..7dd07e8 --- /dev/null +++ b/gerbil-manager-web/src/api/__tests__/names.test.ts @@ -0,0 +1,73 @@ +/** FEAT-NAMEGEN: Tests für buildSuggestPath + NAMEGEN_USAGES. */ +import { describe, expect, it } from 'vitest' +import { buildSuggestPath, NAMEGEN_USAGES } from '../names' + +describe('buildSuggestPath', () => { + it('includes uppercased letter', () => { + const path = buildSuggestPath({ letter: 'f', gender: 'female', usages: ['norn'] }) + expect(path).toContain('letter=F') + }) + + it('omits letter when blank', () => { + const path = buildSuggestPath({ letter: '', usages: ['norn'] }) + expect(path).not.toContain('letter=') + }) + + it('omits letter when only whitespace', () => { + const path = buildSuggestPath({ letter: ' ', usages: ['norn'] }) + expect(path).not.toContain('letter=') + }) + + it('includes gender', () => { + const path = buildSuggestPath({ gender: 'male', usages: ['norn'] }) + expect(path).toContain('gender=male') + }) + + it('omits gender for empty string', () => { + const path = buildSuggestPath({ gender: '', usages: ['norn'] }) + expect(path).not.toContain('gender=') + }) + + it('joins multiple usages without encoding commas', () => { + const path = buildSuggestPath({ usages: ['norn', 'mythg'] }) + expect(path).toContain('usages=norn,mythg') + }) + + it('omits usages when array is empty', () => { + const path = buildSuggestPath({ usages: [] }) + expect(path).not.toContain('usages=') + }) + + it('defaults count to 6', () => { + const path = buildSuggestPath({ usages: ['norn'] }) + expect(path).toContain('count=6') + }) + + it('uses provided count', () => { + const path = buildSuggestPath({ usages: ['norn'], count: 8 }) + expect(path).toContain('count=8') + }) + + it('starts with /names/suggest', () => { + const path = buildSuggestPath({ usages: ['norn'] }) + expect(path).toMatch(/^\/names\/suggest\?/) + }) +}) + +describe('NAMEGEN_USAGES', () => { + it('contains all 5 expected culture codes', () => { + const codes = NAMEGEN_USAGES.map((u) => u.code) + expect(codes).toContain('norn') + expect(codes).toContain('japa') + expect(codes).toContain('mythg') + expect(codes).toContain('ger') + expect(codes).toContain('arb') + expect(codes).toHaveLength(5) + }) + + it('every usage has a non-empty label', () => { + for (const u of NAMEGEN_USAGES) { + expect(u.label.length).toBeGreaterThan(0) + } + }) +}) diff --git a/gerbil-manager-web/src/api/names.ts b/gerbil-manager-web/src/api/names.ts new file mode 100644 index 0000000..21849fd --- /dev/null +++ b/gerbil-manager-web/src/api/names.ts @@ -0,0 +1,40 @@ +/** FEAT-NAMEGEN: Namensvorschläge — Schnittstelle zum Backend /names/suggest. */ +import { api } from './client' + +export interface NameSuggestion { + name: string + meaning: string + origin: string +} + +export const NAMEGEN_USAGES = [ + { code: 'norn', label: 'Nordisch' }, + { code: 'japa', label: 'Japanisch' }, + { code: 'mythg', label: 'Griech. Mythologie' }, + { code: 'ger', label: 'Deutsch' }, + { code: 'arb', label: 'Arabisch' }, +] as const + +export type NamegenUsageCode = (typeof NAMEGEN_USAGES)[number]['code'] + +export interface SuggestNamesParams { + letter?: string + gender?: string + usages: NamegenUsageCode[] + count?: number +} + +/** Exported for unit tests — builds the query path without a network call. */ +export function buildSuggestPath(params: SuggestNamesParams): string { + const parts: string[] = [] + const letter = params.letter?.trim().toUpperCase() + if (letter) parts.push(`letter=${encodeURIComponent(letter)}`) + if (params.gender && params.gender !== '') parts.push(`gender=${encodeURIComponent(params.gender)}`) + if (params.usages.length > 0) parts.push(`usages=${params.usages.join(',')}`) + parts.push(`count=${params.count ?? 6}`) + return `/names/suggest?${parts.join('&')}` +} + +export function suggestNames(params: SuggestNamesParams): Promise { + return api.get(buildSuggestPath(params)) +} diff --git a/gerbil-manager-web/src/components/NameSuggestPanel.css b/gerbil-manager-web/src/components/NameSuggestPanel.css new file mode 100644 index 0000000..5b26554 --- /dev/null +++ b/gerbil-manager-web/src/components/NameSuggestPanel.css @@ -0,0 +1,114 @@ +.namegen-name-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.namegen-name-row .input { + flex: 1; + min-width: 0; +} + +.namegen-panel { + border: 1px solid var(--color-border); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1rem; + background: var(--color-bg, #fff); +} + +.namegen-panel__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + font-weight: 600; +} + +.namegen-panel__filters { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.namegen-panel__letter { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.namegen-panel__letter-input { + width: 5rem; +} + +.namegen-panel__usages { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.namegen-usages-grid { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1rem; +} + +.namegen-usages-grid label { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.9rem; + cursor: pointer; +} + +.namegen-suggestions { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; + border-top: 1px solid var(--color-border); +} + +.namegen-suggestion { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--color-border); + flex-wrap: wrap; +} + +.namegen-suggestion:last-child { + border-bottom: none; +} + +.namegen-suggestion__pick { + font-weight: 600; + color: var(--color-accent, #2563eb); + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + font-size: inherit; + font-family: inherit; + min-width: 6rem; +} + +.namegen-suggestion__pick:hover { + text-decoration: underline; +} + +.namegen-suggestion__meaning { + flex: 1; + font-size: 0.88rem; + color: var(--color-text-muted); + min-width: 8rem; +} + +.namegen-suggestion__origin { + font-size: 0.85rem; + color: var(--color-text-muted); + white-space: nowrap; +} diff --git a/gerbil-manager-web/src/components/NameSuggestPanel.tsx b/gerbil-manager-web/src/components/NameSuggestPanel.tsx new file mode 100644 index 0000000..1927d35 --- /dev/null +++ b/gerbil-manager-web/src/components/NameSuggestPanel.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react' +import { de } from '../strings/de' +import { ApiError, errorCode } from '../api/client' +import { NAMEGEN_USAGES, suggestNames, type NamegenUsageCode, type NameSuggestion } from '../api/names' +import './NameSuggestPanel.css' + +interface NameSuggestPanelProps { + gender: string + onPick: (name: string) => void + onClose: () => void +} + +const ALL_CODES = NAMEGEN_USAGES.map((u) => u.code) as NamegenUsageCode[] + +export default function NameSuggestPanel({ gender, onPick, onClose }: NameSuggestPanelProps) { + const t = de.namegen + const [letter, setLetter] = useState('') + const [usages, setUsages] = useState>(new Set(ALL_CODES)) + const [loading, setLoading] = useState(false) + const [keyMissing, setKeyMissing] = useState(false) + const [error, setError] = useState(null) + const [suggestions, setSuggestions] = useState([]) + const [fetched, setFetched] = useState(false) + + function toggleUsage(code: NamegenUsageCode) { + setUsages((prev) => { + const next = new Set(prev) + if (next.has(code)) next.delete(code) + else next.add(code) + return next + }) + } + + async function load() { + setLoading(true) + setError(null) + setKeyMissing(false) + setSuggestions([]) + try { + const result = await suggestNames({ + letter: letter.trim() || undefined, + gender: gender || undefined, + usages: [...usages] as NamegenUsageCode[], + count: 6, + }) + setSuggestions(result) + setFetched(true) + } catch (err) { + if (errorCode(err) === 'NamesKeyMissing') { + setKeyMissing(true) + } else { + setError(err instanceof ApiError ? err.message : de.api.errors.unknown) + } + } finally { + setLoading(false) + } + } + + return ( +
+
+ {t.panelTitle} + +
+ +
+
+ + setLetter(e.target.value)} + placeholder={t.letterPlaceholder} + maxLength={1} + /> +
+ +
+ {t.usagesLabel} +
+ {NAMEGEN_USAGES.map(({ code, label }) => ( + + ))} +
+
+
+ + + + {keyMissing &&

{t.keyMissing}

} + {error &&

{error}

} + {fetched && !loading && !keyMissing && !error && suggestions.length === 0 && ( +

{t.empty}

+ )} + + {suggestions.length > 0 && ( +
    + {suggestions.map((s, i) => ( +
  • + + {s.meaning} + {s.origin} +
  • + ))} +
+ )} +
+ ) +} diff --git a/gerbil-manager-web/src/pages/GerbilFormPage.tsx b/gerbil-manager-web/src/pages/GerbilFormPage.tsx index 7d38582..0fe805b 100644 --- a/gerbil-manager-web/src/pages/GerbilFormPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilFormPage.tsx @@ -8,6 +8,8 @@ import { useApi, useMutation } from '../hooks/useApi' import { genderLabel, statusLabel } from '../format/labels' import { fromDisplayString } from '../genetics' import FarbschlagImage from '../components/FarbschlagImage' +import NameSuggestPanel from '../components/NameSuggestPanel' +import '../components/NameSuggestPanel.css' interface FormState { name: string @@ -111,6 +113,7 @@ export default function GerbilFormPage() { const [form, setForm] = useState(EMPTY) const [errors, setErrors] = useState>>({}) const [initializedFor, setInitializedFor] = useState(null) + const [showNameSuggest, setShowNameSuggest] = useState(false) const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id]) const colorVarieties = useApi(() => listColorVarieties(), []) @@ -197,16 +200,33 @@ export default function GerbilFormPage() {

{isEdit ? t.form.editTitle : t.form.createTitle}

-