feat(tiere): Eltern direkt in der Tier-Akte bearbeiten (QOL)
Im Tier-Formular gibt es jetzt den Block „Abstammung" mit Wurf-Auswahl und
Vater-/Mutter-Picker — die Eltern müssen nicht mehr über die Wurf-Seite
gesucht und dort editiert werden. Das Datenmodell bleibt unverändert: Eltern
hängen weiterhin am Geburtswurf.
- Backend: PUT /gerbils/{id}/parents schreibt Litter.FatherId/MotherId des
Geburtswurfs. Ohne Wurf wird ein bestehender mit gleichem Elternpaar +
gleichem Datum verknüpft, sonst ein Träger-Wurf angelegt
("Wurf von X + Y", ShowInChronicle=false, IsManual=true).
Geschlechts-Regel wiederverwendet LitterEndpoints.ValidateParents,
Selbstbezug (Tier als eigener Elternteil) wird abgewiesen.
- UI: Vorbelegung aus dem gewählten Wurf, Warnung mit Anzahl der Geschwister
(Eltern gehören dem Wurf → Änderung gilt für alle), Hinweis wenn ein
Wurf-Eintrag angelegt wird. Texte in de.ts.
- Nebenbei: Speichern nutzt im Edit-Modus die Route-Id (PUT /gerbils/{id}
antwortet 204 ohne Body) und der vorher schon rote Spec-Locator
„Würfe als Elternteil" ist auf den Abschnitt eingegrenzt.
- Tests: GerbilParentsTests (9), e2e tiere.spec (2 neu) + Mock-Route.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -936,6 +936,79 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 200, results)
|
||||
}
|
||||
|
||||
// QOL-ELTERN: Eltern direkt an der Tier-Akte setzen (PUT /gerbils/:id/parents).
|
||||
// Spiegelt das Backend: Eltern werden am GEBURTSWURF gespeichert; fehlt der Wurf, wird
|
||||
// ein bestehender mit gleichen Eltern + gleichem Datum verknüpft, sonst einer angelegt
|
||||
// (showInChronicle=false → nicht in der Wurfchronik).
|
||||
const parentsMatch = path.match(/^\/gerbils\/([^/]+)\/parents$/)
|
||||
if (parentsMatch) {
|
||||
if (method !== 'PUT') return json(route, 405)
|
||||
const gid = decodeURIComponent(parentsMatch[1])
|
||||
const g = db.gerbils.find((x) => x.id === gid)
|
||||
if (!g) return json(route, 404, { title: 'Not Found' })
|
||||
const body = (request.postDataJSON() ?? {}) as {
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
}
|
||||
const fatherId = body.fatherId ?? null
|
||||
const motherId = body.motherId ?? null
|
||||
const father = fatherId ? db.gerbils.find((x) => x.id === fatherId) : null
|
||||
const mother = motherId ? db.gerbils.find((x) => x.id === motherId) : null
|
||||
if (father?.gender === 'female' || mother?.gender === 'male') {
|
||||
return json(route, 400, {
|
||||
code: 'InvalidParentGender',
|
||||
fatherGender: father?.gender ?? 'unknown',
|
||||
motherGender: mother?.gender ?? 'unknown',
|
||||
})
|
||||
}
|
||||
let litter = g.litterId ? (db.litters.find((l) => l.id === g.litterId) ?? null) : null
|
||||
let litterCreated = false
|
||||
let litterAttached = false
|
||||
if (!litter && !fatherId && !motherId) {
|
||||
return json(route, 200, {
|
||||
litterId: null, litterName: null, litterCreated: false, litterAttached: false,
|
||||
siblingCount: 0, fatherId: null, motherId: null,
|
||||
})
|
||||
}
|
||||
if (!litter) {
|
||||
if (g.dateOfBirth) {
|
||||
litter =
|
||||
db.litters.find(
|
||||
(l) => l.fatherId === fatherId && l.motherId === motherId && l.date === g.dateOfBirth,
|
||||
) ?? null
|
||||
litterAttached = litter !== null
|
||||
}
|
||||
if (!litter) {
|
||||
litter = {
|
||||
id: newId('litter'),
|
||||
name: `Wurf von ${father?.name ?? '—'} + ${mother?.name ?? '—'}`,
|
||||
date: g.dateOfBirth ?? '',
|
||||
totalBorn: null,
|
||||
expectedGoHomeDate: null,
|
||||
notes: null,
|
||||
fatherId,
|
||||
motherId,
|
||||
showInChronicle: false,
|
||||
}
|
||||
db.litters.push(litter)
|
||||
litterCreated = true
|
||||
}
|
||||
g.litterId = litter.id
|
||||
}
|
||||
litter.fatherId = fatherId
|
||||
litter.motherId = motherId
|
||||
const siblingCount = db.gerbils.filter((x) => x.litterId === litter!.id && x.id !== gid).length
|
||||
return json(route, 200, {
|
||||
litterId: litter.id,
|
||||
litterName: litter.name,
|
||||
litterCreated,
|
||||
litterAttached,
|
||||
siblingCount,
|
||||
fatherId,
|
||||
motherId,
|
||||
})
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import type { Page } from '@playwright/test'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
@@ -202,18 +203,73 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||
})
|
||||
|
||||
/** QOL-ELTERN: Vater/Mutter im Abstammungs-Block des Tier-Formulars setzen. */
|
||||
async function pickParent(page: Page, legend: string, name: string) {
|
||||
const group = page.getByRole('group', { name: legend })
|
||||
await group.getByRole('searchbox').fill(name)
|
||||
await group.getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
test('Tier bearbeiten — Eltern direkt an der Akte setzen, Wurf wird angelegt (QOL-ELTERN)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Maja hat keinen Geburtswurf → beim Speichern wird einer für die Eltern angelegt.
|
||||
await page.goto('/rennmaeuse/maja/bearbeiten')
|
||||
await expect(page.getByRole('heading', { name: t.form.editTitle })).toBeVisible()
|
||||
await expect(page.getByText(t.form.parentsSectionTitle)).toBeVisible()
|
||||
|
||||
await pickParent(page, t.detail.parentLittersRoleVater, 'Anton')
|
||||
await pickParent(page, t.detail.parentLittersRoleMutter, 'Greta')
|
||||
// Hinweis, dass ein Wurf-Eintrag angelegt wird (kein Wurf vorhanden).
|
||||
await expect(page.getByTestId('parents-new-litter-hint')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
|
||||
// Akte zeigt die neuen Eltern als Links.
|
||||
await expect(page.getByRole('heading', { name: 'Maja' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Anton', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Greta', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier bearbeiten — Eltern-Wechsel warnt vor den Geschwistern desselben Wurfs (QOL-ELTERN)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hängt an Wurf K (Vater Fridolin, Mutter Luna) mit 2 weiteren Jungtieren.
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
const vater = page.getByRole('group', { name: t.detail.parentLittersRoleVater })
|
||||
await expect(vater.getByText('Fridolin')).toBeVisible()
|
||||
|
||||
// Vater tauschen → Warnung, dass die Änderung auch für die Geschwister gilt.
|
||||
await vater.getByRole('button', { name: t.form.parentsClear }).click()
|
||||
await pickParent(page, t.detail.parentLittersRoleVater, 'Anton')
|
||||
await expect(page.getByTestId('parents-sibling-warning')).toContainText('2')
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Anton', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
// Wurf K als Link sichtbar — gezielt IM Abschnitt „Würfe als Elternteil": der Abschnitt
|
||||
// „Nachzucht in der Zucht" nennt den Wurfnamen ebenfalls (Krümel-Karte) und würde die
|
||||
// Suche über die ganze Seite mehrdeutig machen.
|
||||
const littersSection = page.locator('section.ak-card', {
|
||||
has: page.getByRole('heading', { name: t.detail.parentLittersTitle }),
|
||||
})
|
||||
const litterLink = littersSection.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
|
||||
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
|
||||
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
|
||||
await expect(
|
||||
littersSection.locator('.badge', { hasText: t.detail.parentLittersRoleVater }).first(),
|
||||
).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
Reference in New Issue
Block a user