Compare commits
4 Commits
c9723d2fc8
...
adea82a0e7
| Author | SHA1 | Date | |
|---|---|---|---|
| adea82a0e7 | |||
| ebd61d73a5 | |||
| ec5e755e72 | |||
| 8d7999c4a5 |
4
gerbil-manager-web/.gitignore
vendored
4
gerbil-manager-web/.gitignore
vendored
@@ -22,3 +22,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# QA-1: Playwright-Artefakte
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
@@ -39,3 +39,21 @@ Erwartet die laufende GerbilManagerWebAPI unter `http://localhost:5179`
|
|||||||
npm run build
|
npm run build
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## E2E-Tests (QA-1, Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run e2e # Mock-Modus: startet Vite auf :5199, API wird gemockt
|
||||||
|
npx playwright test --project=phone # nur Smartphone-Viewport (390px)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Mock-Modus (Standard):** kein Backend nötig. `e2e/mock-api.ts` fängt alle
|
||||||
|
API-Aufrufe ab (DATA-2-Vertrag: Gridify-Paging, camelCase, 409-Konflikte)
|
||||||
|
mit frischem Datenbestand pro Test (`e2e/mock-data.ts`).
|
||||||
|
- **Live-Modus:** dieselbe Suite gegen den echten Stack —
|
||||||
|
`$env:E2E_BASE_URL = 'http://localhost:5173'; npm run e2e`
|
||||||
|
(vorher AppHost starten). Tests, die Mock-Seed-Daten voraussetzen,
|
||||||
|
überspringen sich selbst; die übrigen legen eigene Datensätze an.
|
||||||
|
- Beide Viewports (Smartphone 390px, Laptop 1280px) laufen für jede Spec;
|
||||||
|
alle Assertions prüfen die deutschen Oberflächentexte direkt aus
|
||||||
|
`src/strings/de.ts`.
|
||||||
|
|||||||
87
gerbil-manager-web/e2e/becken-kontakte.spec.ts
Normal file
87
gerbil-manager-web/e2e/becken-kontakte.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/** QA-1: Becken + Kontakte (FEAT-2) — CRUD, Belegung, 409-Konfliktpfad. */
|
||||||
|
import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
|
const tb = de.pages.becken
|
||||||
|
const tk = de.pages.kontakte
|
||||||
|
|
||||||
|
test.describe('Becken', () => {
|
||||||
|
test('Belegung: Detail zeigt Bewohner mit Link zur Tierakte', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/becken')
|
||||||
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Großbecken' })).toBeVisible()
|
||||||
|
await expect(page.getByText(`2 ${tb.occupancy.countMany}`)).toBeVisible()
|
||||||
|
await expect(page.getByText(tb.occupancy.title)).toBeVisible()
|
||||||
|
await page.getByRole('link', { name: /Krümel/ }).click()
|
||||||
|
await expect(page.getByText(de.pages.gerbils.detail.masterData)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Löschen eines belegten Beckens meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/becken')
|
||||||
|
await page.getByRole('link', { name: /Großbecken/ }).click()
|
||||||
|
acceptNextDialog(page)
|
||||||
|
await page.getByRole('button', { name: tb.delete.action }).click()
|
||||||
|
await expect(page.getByText(tb.delete.conflict)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Becken anlegen, bearbeiten und (leer) löschen', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.enclosures)
|
||||||
|
await page.getByRole('link', { name: tb.newButton }).click()
|
||||||
|
|
||||||
|
// Pflichtfeld-Validierung
|
||||||
|
await page.getByRole('button', { name: tb.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByText(tb.validation.nameRequired)).toBeVisible()
|
||||||
|
|
||||||
|
const name = uniqueName('Becken')
|
||||||
|
await page.getByLabel(`${tb.fields.name} *`).fill(name)
|
||||||
|
await page.getByLabel(tb.fields.notes).fill('Übergangsbecken')
|
||||||
|
await page.getByRole('button', { name: tb.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
await expect(page.getByText(tb.occupancy.empty)).toBeVisible()
|
||||||
|
|
||||||
|
// Leeres Becken löschen -> zurück zur Liste
|
||||||
|
acceptNextDialog(page)
|
||||||
|
await page.getByRole('button', { name: tb.delete.action }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: tb.title, exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name })).toBeHidden()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Kontakte', () => {
|
||||||
|
test('Detail zeigt verknüpfte Tiere mit Rollen-Beschriftung', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/kontakte')
|
||||||
|
await page.getByRole('link', { name: /Zoohandlung Meier/ }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Zoohandlung Meier' })).toBeVisible()
|
||||||
|
await expect(page.getByText(tk.linked.title)).toBeVisible()
|
||||||
|
// "Herkunft von Krümel"
|
||||||
|
await expect(page.getByText(`${tk.linked.originRole} Krümel`)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Löschen eines referenzierten Kontakts meldet den 409-Konflikt auf Deutsch', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/kontakte')
|
||||||
|
await page.getByRole('link', { name: /Familie Huber/ }).click()
|
||||||
|
// Pauli (abgegeben) referenziert Familie Huber als Abnehmer
|
||||||
|
await expect(page.getByText(`${tk.linked.receiverRole} Pauli`)).toBeVisible()
|
||||||
|
acceptNextDialog(page)
|
||||||
|
await page.getByRole('button', { name: tk.delete.action }).click()
|
||||||
|
await expect(page.getByText(tk.delete.conflict)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kontakt anlegen und (unverknüpft) wieder löschen', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.contacts)
|
||||||
|
await page.getByRole('link', { name: tk.newButton }).click()
|
||||||
|
const name = uniqueName('Kontakt')
|
||||||
|
await page.getByLabel(`${tk.fields.name} *`).fill(name)
|
||||||
|
await page.getByLabel(tk.fields.contactInfo).fill('test@example.de')
|
||||||
|
await page.getByRole('button', { name: tk.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
await expect(page.getByText(tk.linked.empty)).toBeVisible()
|
||||||
|
|
||||||
|
acceptNextDialog(page)
|
||||||
|
await page.getByRole('button', { name: tk.delete.action }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: tk.title, exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
68
gerbil-manager-web/e2e/fixtures.ts
Normal file
68
gerbil-manager-web/e2e/fixtures.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* QA-1: Test-Fixtures.
|
||||||
|
*
|
||||||
|
* - MOCK-Modus (Standard): installiert vor jedem Test den In-Memory-Mock
|
||||||
|
* (frischer Datenbestand pro Test).
|
||||||
|
* - LIVE-Modus (E2E_BASE_URL gesetzt): kein Mock; fixture-gebundene Tests
|
||||||
|
* überspringen sich über `skipUnlessMock()`.
|
||||||
|
*
|
||||||
|
* Deutsche Oberflächen-Texte werden DIREKT aus src/strings/de.ts importiert —
|
||||||
|
* die Suite bleibt damit automatisch synchron zur App.
|
||||||
|
*/
|
||||||
|
import { test as base, expect, type Page } from '@playwright/test'
|
||||||
|
import { de } from '../src/strings/de'
|
||||||
|
import { installMockApi } from './mock-api'
|
||||||
|
import type { MockDb } from './mock-data'
|
||||||
|
|
||||||
|
export const LIVE = Boolean(process.env.E2E_BASE_URL)
|
||||||
|
|
||||||
|
export { expect }
|
||||||
|
export { de }
|
||||||
|
|
||||||
|
interface Fixtures {
|
||||||
|
/** Im Mock-Modus der pro Test frische Datenbestand, sonst null. */
|
||||||
|
mockDb: MockDb | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<Fixtures>({
|
||||||
|
mockDb: [
|
||||||
|
async ({ page }, use) => {
|
||||||
|
const db = LIVE ? null : await installMockApi(page)
|
||||||
|
await use(db)
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Test überspringen, wenn er Seed-Daten des Mocks voraussetzt. */
|
||||||
|
export function skipUnlessMock() {
|
||||||
|
test.skip(LIVE, 'benötigt die Mock-Seed-Daten (läuft nicht im LIVE-Modus)')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zu einem Navigationsziel wechseln — auf dem Smartphone liegen Becken,
|
||||||
|
* Kontakte und Statistik hinter dem „Mehr“-Blatt.
|
||||||
|
*/
|
||||||
|
export async function gotoSection(page: Page, label: string) {
|
||||||
|
const nav = page.getByRole('navigation')
|
||||||
|
// App noch nicht geladen (about:blank) -> erst zur Startseite
|
||||||
|
if (!(await nav.isVisible())) {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(nav).toBeVisible()
|
||||||
|
}
|
||||||
|
const link = nav.getByRole('link', { name: label })
|
||||||
|
if (!(await link.isVisible())) {
|
||||||
|
await nav.getByRole('button', { name: de.nav.more }).click()
|
||||||
|
await expect(link).toBeVisible()
|
||||||
|
}
|
||||||
|
await link.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** window.confirm des nächsten Dialogs annehmen. */
|
||||||
|
export function acceptNextDialog(page: Page) {
|
||||||
|
page.once('dialog', (d) => void d.accept())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eindeutiger Name für LIVE-taugliche Create-Flows. */
|
||||||
|
export const uniqueName = (prefix: string) =>
|
||||||
|
`${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||||
40
gerbil-manager-web/e2e/genetik.spec.ts
Normal file
40
gerbil-manager-web/e2e/genetik.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** QA-1: Genetik (FEAT-5/GEN-1) — Probeverpaarung inkl. SCHECKE-Warnung. */
|
||||||
|
import { de, expect, gotoSection, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.genetik
|
||||||
|
|
||||||
|
function genotypeInput(page: import('@playwright/test').Page, parent: string) {
|
||||||
|
return page
|
||||||
|
.locator('fieldset.parent-selector')
|
||||||
|
.filter({ has: page.getByText(parent, { exact: true }) })
|
||||||
|
.getByLabel(t.genotypeLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Probeverpaarung rechnet client-seitig und zeigt Nachkommen-Farbschläge', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.genetics)
|
||||||
|
await expect(page.getByText(t.subtitle)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.needBoth)).toBeVisible()
|
||||||
|
|
||||||
|
await genotypeInput(page, t.father).fill('AA CC DD EE GG PP spsp rere')
|
||||||
|
await genotypeInput(page, t.mother).fill('aa CC DD EE GG PP spsp rere')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: t.resultsTitle })).toBeVisible()
|
||||||
|
// AA × aa -> alle Nachkommen Aa = Agouti (Farbschlag-Karte)
|
||||||
|
await expect(page.locator('.farbschlag-card').filter({ hasText: 'Agouti' }).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Schecke × Schecke zeigt die deutsche Letalwarnung', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.genetics)
|
||||||
|
// Beide Eltern Spsp (Schecke): SpSp ist letal -> Warnhinweis
|
||||||
|
await genotypeInput(page, t.father).fill('Aa CC DD EE GG PP Spsp rere')
|
||||||
|
await genotypeInput(page, t.mother).fill('Aa CC DD EE GG PP Spsp rere')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: t.warningsTitle })).toBeVisible()
|
||||||
|
await expect(page.getByText(de.genetics.warnings.SCHECKE_LETHAL)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ungültiger Genotyp zeigt die deutsche Fehlermeldung', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.genetics)
|
||||||
|
await genotypeInput(page, t.father).fill('kein genotyp')
|
||||||
|
await expect(page.getByText(t.genotypeInvalid)).toBeVisible()
|
||||||
|
})
|
||||||
163
gerbil-manager-web/e2e/mock-api.ts
Normal file
163
gerbil-manager-web/e2e/mock-api.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* QA-1: Stateful In-Memory-Mock der GerbilManagerWebAPI per Route-Interception.
|
||||||
|
*
|
||||||
|
* Bedient den DATA-2-Vertrag: Gridify-paged Listen ({items, totalCount, page,
|
||||||
|
* pageSize}), camelCase-DTOs, 409 Conflict bei referenzierten Becken/Kontakten
|
||||||
|
* (von Dwight bestätigt). Jeder Test bekommt über die Fixture in fixtures.ts
|
||||||
|
* einen FRISCHEN Datenbestand — CRUD-Flüsse mutieren ihn wie das echte Backend.
|
||||||
|
*/
|
||||||
|
import type { Page, Route } from '@playwright/test'
|
||||||
|
import { seedDb, type MockDb } from './mock-data'
|
||||||
|
|
||||||
|
/** Default-API-Origin des Frontends (src/api/client.ts). */
|
||||||
|
const API_ORIGIN = 'http://localhost:5179'
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
const newId = (prefix: string) => `${prefix}-e2e-${++seq}`
|
||||||
|
|
||||||
|
/** Gridify-Escapes entfernen (\, vor Sonderzeichen). */
|
||||||
|
const unescapeGridify = (s: string) => s.replace(/\\(.)/g, '$1')
|
||||||
|
|
||||||
|
/** Mini-Gridify: genau die Ausdrücke, die die App baut (==, contains, ',', '|'). */
|
||||||
|
function matchesFilter(row: Row, filter: string | null): boolean {
|
||||||
|
if (!filter) return true
|
||||||
|
return filter.split(',').every((andPart) =>
|
||||||
|
andPart.split('|').some((cond) => {
|
||||||
|
let m = cond.match(/^(\w+)=\*(.*)\*(\/i)?$/)
|
||||||
|
if (m) {
|
||||||
|
return String(row[m[1]] ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(unescapeGridify(m[2]).toLowerCase())
|
||||||
|
}
|
||||||
|
m = cond.match(/^(\w+)==(.*)$/)
|
||||||
|
if (m) return String(row[m[1]] ?? '') === unescapeGridify(m[2])
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOrderBy(rows: Row[], orderBy: string | null): Row[] {
|
||||||
|
if (!orderBy) return rows
|
||||||
|
const [field, dir] = orderBy.split(/\s+/)
|
||||||
|
const sign = dir?.toLowerCase() === 'desc' ? -1 : 1
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = String(a[field] ?? '')
|
||||||
|
const bv = String(b[field] ?? '')
|
||||||
|
return av < bv ? -sign : av > bv ? sign : 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedResponse(rows: Row[], url: URL) {
|
||||||
|
const filtered = rows.filter((r) => matchesFilter(r, url.searchParams.get('filter')))
|
||||||
|
const sorted = applyOrderBy(filtered, url.searchParams.get('orderBy'))
|
||||||
|
const page = Number(url.searchParams.get('page') ?? '1')
|
||||||
|
const pageSize = Number(url.searchParams.get('pageSize') ?? '20')
|
||||||
|
return {
|
||||||
|
items: sorted.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
totalCount: filtered.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (route: Route, status: number, body?: unknown) =>
|
||||||
|
route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
body: body === undefined ? '' : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische CRUD-Kollektion. `deleteConflict` bildet die 409-Regel des
|
||||||
|
* Backends ab (Becken nicht leer / Kontakt referenziert).
|
||||||
|
*/
|
||||||
|
function collection(
|
||||||
|
rows: Row[],
|
||||||
|
idPrefix: string,
|
||||||
|
deleteConflict?: (id: string, db: MockDb) => boolean,
|
||||||
|
) {
|
||||||
|
return { rows, idPrefix, deleteConflict }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installMockApi(page: Page): Promise<MockDb> {
|
||||||
|
const db = seedDb()
|
||||||
|
|
||||||
|
const collections: Record<string, ReturnType<typeof collection>> = {
|
||||||
|
gerbils: collection(db.gerbils as unknown as Row[], 'gerbil'),
|
||||||
|
litters: collection(db.litters as unknown as Row[], 'litter'),
|
||||||
|
enclosures: collection(db.enclosures as unknown as Row[], 'enc', (id, d) =>
|
||||||
|
d.gerbils.some((g) => g.enclosureId === id),
|
||||||
|
),
|
||||||
|
contacts: collection(db.contacts as unknown as Row[], 'con', (id, d) =>
|
||||||
|
d.gerbils.some((g) => g.originContactId === id || g.receiverContactId === id),
|
||||||
|
),
|
||||||
|
'color-varieties': collection(db.colorVarieties as unknown as Row[], 'cv'),
|
||||||
|
'health-records': collection(db.healthRecords as unknown as Row[], 'hr'),
|
||||||
|
'weight-records': collection(db.weightRecords as unknown as Row[], 'wr'),
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.route(`${API_ORIGIN}/**`, async (route) => {
|
||||||
|
const request = route.request()
|
||||||
|
const url = new URL(request.url())
|
||||||
|
const method = request.method()
|
||||||
|
const path = url.pathname
|
||||||
|
|
||||||
|
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
|
||||||
|
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
|
||||||
|
if (m) {
|
||||||
|
if (method === 'GET') return json(route, 200, [])
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
if (path.match(/^\/photos\/[^/]+$/) && method === 'DELETE') return json(route, 204)
|
||||||
|
m = path.match(/^\/gerbils\/([^/]+)\/inbreeding-coefficient$/)
|
||||||
|
if (m) {
|
||||||
|
const isKruemel = m[1] === 'kruemel'
|
||||||
|
return json(route, 200, {
|
||||||
|
coefficient: isKruemel ? 0.0625 : 0,
|
||||||
|
percent: isKruemel ? 6.25 : 0,
|
||||||
|
generationsAvailable: isKruemel ? 5 : 2,
|
||||||
|
commonAncestors: isKruemel
|
||||||
|
? [{ id: 'anton', name: 'Anton', contribution: 0.0625 }]
|
||||||
|
: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
if (!m || !col) return json(route, 404, { title: 'Not Found' })
|
||||||
|
const id = m[2] ? decodeURIComponent(m[2]) : null
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
if (method === 'GET') return json(route, 200, pagedResponse(col.rows, url))
|
||||||
|
if (method === 'POST') {
|
||||||
|
const body = request.postDataJSON() as Row
|
||||||
|
const created = { id: newId(col.idPrefix), ...body }
|
||||||
|
col.rows.push(created)
|
||||||
|
return json(route, 201, created)
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = col.rows.findIndex((r) => r.id === id)
|
||||||
|
if (method === 'GET') {
|
||||||
|
return idx >= 0 ? json(route, 200, col.rows[idx]) : json(route, 404, { title: 'Not Found' })
|
||||||
|
}
|
||||||
|
if (method === 'PUT') {
|
||||||
|
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
|
Object.assign(col.rows[idx], request.postDataJSON() as Row)
|
||||||
|
return json(route, 200, col.rows[idx])
|
||||||
|
}
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
if (idx < 0) return json(route, 404, { title: 'Not Found' })
|
||||||
|
if (col.deleteConflict?.(id, db)) return json(route, 409, { title: 'Conflict' })
|
||||||
|
col.rows.splice(idx, 1)
|
||||||
|
return json(route, 204)
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
})
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
141
gerbil-manager-web/e2e/mock-data.ts
Normal file
141
gerbil-manager-web/e2e/mock-data.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* QA-1: Seed-Daten für den Mock-Modus — geformt exakt nach dem DATA-2-Vertrag
|
||||||
|
* (camelCase-DTOs, Gridify-paged Listen). Datensatz angelehnt an Kellys
|
||||||
|
* FEAT-4-Verifikationsdaten (mehrgenerationiger Stammbaum inkl. gemeinsamem
|
||||||
|
* Vorfahren Anton), erweitert um Becken/Kontakte/Gesundheit/Gewicht für die
|
||||||
|
* FEAT-2/FEAT-6-Flüsse.
|
||||||
|
*/
|
||||||
|
import type { Contact, Enclosure, Gerbil, Litter } from '../src/api/types'
|
||||||
|
|
||||||
|
export interface HealthRecordRow {
|
||||||
|
id: string
|
||||||
|
gerbilId: string
|
||||||
|
date: string
|
||||||
|
type: string
|
||||||
|
description: string
|
||||||
|
veterinarian: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeightRecordRow {
|
||||||
|
id: string
|
||||||
|
gerbilId: string
|
||||||
|
date: string
|
||||||
|
weightGrams: number
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockDb {
|
||||||
|
gerbils: Gerbil[]
|
||||||
|
litters: Litter[]
|
||||||
|
enclosures: Enclosure[]
|
||||||
|
contacts: Contact[]
|
||||||
|
colorVarieties: { id: string; name: string; canonicalGenotype: string | null; sortOrder: number }[]
|
||||||
|
healthRecords: HealthRecordRow[]
|
||||||
|
weightRecords: WeightRecordRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function gerbil(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
gender: 'male' | 'female',
|
||||||
|
dateOfBirth: string,
|
||||||
|
litterId: string | null,
|
||||||
|
colorVarietyId: string | null,
|
||||||
|
genotype: string | null = null,
|
||||||
|
): Gerbil {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
gender,
|
||||||
|
status: 'Active',
|
||||||
|
dateOfBirth,
|
||||||
|
dateOfDeath: null,
|
||||||
|
causeOfDeath: null,
|
||||||
|
goHomeDate: null,
|
||||||
|
litterId,
|
||||||
|
enclosureId: null,
|
||||||
|
colorVarietyId,
|
||||||
|
originContactId: null,
|
||||||
|
receiverContactId: null,
|
||||||
|
genotype,
|
||||||
|
notes: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frischer, unabhängiger Datenbestand pro Test. */
|
||||||
|
export function seedDb(): MockDb {
|
||||||
|
const gerbils: Gerbil[] = [
|
||||||
|
{
|
||||||
|
...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'),
|
||||||
|
enclosureId: 'enc-gross',
|
||||||
|
originContactId: 'con-meier',
|
||||||
|
},
|
||||||
|
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross' },
|
||||||
|
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||||
|
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||||
|
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||||
|
gerbil('karlsson', 'Karlsson', 'male', '2022-02-02', 'w-karlsson', 'cv-blau'),
|
||||||
|
gerbil('smilla', 'Smilla', 'female', '2022-09-30', null, 'cv-himalaya'),
|
||||||
|
gerbil('anton', 'Anton', 'male', '2019-07-07', 'w-anton', 'cv-agouti'),
|
||||||
|
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
||||||
|
gerbil('frieda', 'Frieda', 'female', '2020-01-18', null, 'cv-gold'),
|
||||||
|
gerbil('emil', 'Emil', 'male', '2017-03-03', 'w-emil', 'cv-agouti'),
|
||||||
|
gerbil('hilde', 'Hilde', 'female', '2017-11-11', null, 'cv-schwarz'),
|
||||||
|
gerbil('max', 'Max', 'male', '2015-08-08', null, 'cv-agouti'),
|
||||||
|
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||||
|
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||||
|
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||||
|
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const litters: Litter[] = [
|
||||||
|
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna' },
|
||||||
|
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||||
|
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||||
|
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||||
|
// Anton ist auch Karlssons Vater -> gemeinsamer Vorfahre (Inzucht-Demo)
|
||||||
|
{ id: 'w-karlsson', name: 'Wurf C', date: '2022-02-02', totalBorn: 5, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'frieda' },
|
||||||
|
{ id: 'w-anton', name: 'Wurf A', date: '2019-07-07', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'emil', motherId: 'hilde' },
|
||||||
|
{ id: 'w-emil', name: 'Wurf E', date: '2017-03-03', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'max', motherId: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const enclosures: Enclosure[] = [
|
||||||
|
{ id: 'enc-gross', name: 'Großbecken', notes: '120×50 cm' },
|
||||||
|
{ id: 'enc-leer', name: 'Quarantänebecken', notes: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
const contacts: Contact[] = [
|
||||||
|
{ id: 'con-meier', name: 'Zoohandlung Meier', contactInfo: 'meier@example.de', notes: null },
|
||||||
|
{ id: 'con-huber', name: 'Familie Huber', contactInfo: '0151 2345678', notes: null },
|
||||||
|
{ id: 'con-frei', name: 'Züchterin Frei', contactInfo: null, notes: 'unverknüpft' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const colorVarieties = [
|
||||||
|
{ id: 'cv-agouti', name: 'Agouti', canonicalGenotype: 'AA CC DD EE GG PP spsp rere', sortOrder: 1 },
|
||||||
|
{ id: 'cv-schwarz', name: 'Schwarz', canonicalGenotype: 'aa CC DD EE GG PP spsp rere', sortOrder: 2 },
|
||||||
|
{ id: 'cv-gold', name: 'Gold', canonicalGenotype: 'AA CC DD EE GG pp spsp rere', sortOrder: 3 },
|
||||||
|
{ id: 'cv-blau', name: 'Blau', canonicalGenotype: 'aa CC dd EE GG PP spsp rere', sortOrder: 4 },
|
||||||
|
{ id: 'cv-himalaya', name: 'Himalaya', canonicalGenotype: 'AA chch DD EE GG PP spsp rere', sortOrder: 5 },
|
||||||
|
{ id: 'cv-schwarz-schecke', name: 'Schwarz Schecke', canonicalGenotype: 'aa CC DD EE GG PP Spsp rere', sortOrder: 6 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const healthRecords: HealthRecordRow[] = [
|
||||||
|
{
|
||||||
|
id: 'hr-1',
|
||||||
|
gerbilId: 'kruemel',
|
||||||
|
date: '2026-01-15',
|
||||||
|
type: 'Vaccination',
|
||||||
|
description: 'Jahresimpfung',
|
||||||
|
veterinarian: 'Dr. Vogel',
|
||||||
|
createdAt: '2026-01-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const weightRecords: WeightRecordRow[] = [
|
||||||
|
{ id: 'wr-1', gerbilId: 'kruemel', date: '2026-05-01', weightGrams: 78, notes: null },
|
||||||
|
{ id: 'wr-2', gerbilId: 'kruemel', date: '2026-05-20', weightGrams: 82, notes: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
return { gerbils, litters, enclosures, contacts, colorVarieties, healthRecords, weightRecords }
|
||||||
|
}
|
||||||
34
gerbil-manager-web/e2e/navigation.spec.ts
Normal file
34
gerbil-manager-web/e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** QA-1: Grundnavigation — alle Bereiche erreichbar (Smartphone: „Mehr“-Blatt). */
|
||||||
|
import { de, expect, gotoSection, test } from './fixtures'
|
||||||
|
|
||||||
|
test('alle Bereiche sind über die Navigation erreichbar', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('heading', { name: de.app.title })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.gerbils.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.litters)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.litters.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.genetics)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.genetik.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
// Sekundärbereiche (auf dem Smartphone hinter „Mehr“)
|
||||||
|
await gotoSection(page, de.nav.enclosures)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.becken.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.contacts)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.kontakte.title, exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await gotoSection(page, de.nav.statistics)
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.statistik.title })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unbekannte Route zeigt die deutsche 404-Seite', async ({ page }) => {
|
||||||
|
await page.goto('/diese-seite-gibt-es-nicht')
|
||||||
|
await expect(page.getByText(de.pages.notFound.message)).toBeVisible()
|
||||||
|
await page.getByRole('link', { name: de.pages.notFound.backHome }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: de.pages.home.title })).toBeVisible()
|
||||||
|
})
|
||||||
55
gerbil-manager-web/e2e/stammbaum.spec.ts
Normal file
55
gerbil-manager-web/e2e/stammbaum.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/** QA-1: Stammbaum (FEAT-4) — interaktiver Viewer, Re-Root, Druckansicht. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.stammbaum
|
||||||
|
|
||||||
|
test('Viewer rendert die Ahnen-Karten und re-rootet per Tipp aufs Elterntier', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: t.titleFor('Krümel') })).toBeVisible()
|
||||||
|
|
||||||
|
// Erst die Ansicht einpassen, damit alle Karten im SVG-Viewport liegen
|
||||||
|
// (sonst tippt der Klick auf dem Laptop ins Leere).
|
||||||
|
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||||
|
if (await fit.isVisible()) await fit.click()
|
||||||
|
|
||||||
|
// Tipp auf den Vater -> Stammbaum wurzelt neu auf Fridolin.
|
||||||
|
// (force: die d3-Baum-Animation bewegt die Karten noch eine Weile —
|
||||||
|
// Playwrights Stabilitäts-Check würde sonst dauerhaft warten.)
|
||||||
|
await page.waitForTimeout(900)
|
||||||
|
await page
|
||||||
|
.locator('.pedigree-card:not(.pedigree-card--root):not(.pedigree-card--unknown)')
|
||||||
|
.filter({ hasText: 'Fridolin' })
|
||||||
|
.first()
|
||||||
|
.click({ force: true })
|
||||||
|
await page.waitForURL('**/rennmaeuse/fridolin/stammbaum')
|
||||||
|
await expect(page.getByRole('heading', { name: t.titleFor('Fridolin') })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Inzuchtkoeffizient wird angezeigt', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
const inzucht = page.locator('.stammbaum-inzucht')
|
||||||
|
await expect(inzucht).toBeVisible()
|
||||||
|
await expect(inzucht).toContainText(t.inbreeding.label)
|
||||||
|
// 0.0625 -> de-DE mit max. 1 Nachkommastelle -> "6,3 %"
|
||||||
|
await expect(inzucht).toContainText('6,3')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Druckansicht (Ahnentafel) erscheint im Print-Medium', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.emulateMedia({ media: 'print' })
|
||||||
|
// Spaltenüberschriften der Ahnentafel + Erstellt-am-Zeile
|
||||||
|
await expect(page.getByText(t.printView.generations[1], { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText(new RegExp(t.printView.createdOn))).toBeVisible()
|
||||||
|
await page.emulateMedia({ media: 'screen' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unbekanntes Tier zeigt den deutschen Nicht-gefunden-Zustand', async ({ page }) => {
|
||||||
|
await page.goto('/rennmaeuse/gibt-es-nicht/stammbaum')
|
||||||
|
await expect(page.getByText(t.notFound).or(page.getByText(de.api.errors.notFound))).toBeVisible()
|
||||||
|
})
|
||||||
31
gerbil-manager-web/e2e/statistik.spec.ts
Normal file
31
gerbil-manager-web/e2e/statistik.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** QA-1: Statistik & Berichte (FEAT-7) — Auswertungen rendern. */
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.statistik
|
||||||
|
|
||||||
|
test('Statistikseite lädt (leer oder mit Daten, aber deutsch)', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.statistics)
|
||||||
|
await expect(page.getByRole('heading', { name: t.title })).toBeVisible()
|
||||||
|
// Live ohne Daten: leerer Zustand; sonst die Auswertungs-Karten
|
||||||
|
await expect(
|
||||||
|
page.getByText(t.empty).or(page.getByRole('heading', { name: t.littersPerYear })),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('alle fünf Auswertungen rendern mit den Seed-Daten', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await gotoSection(page, de.nav.statistics)
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: t.littersPerYear })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: t.avgLitterSize })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: t.farbschlag })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: t.population })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: t.losses })).toBeVisible()
|
||||||
|
|
||||||
|
// Diagramme: Balken (Würfe/Jahr), horizontale Farbschlag-Balken, Bestandskurve
|
||||||
|
await expect(page.locator('svg').first()).toBeVisible()
|
||||||
|
await expect(page.locator('.hbar').first()).toBeVisible()
|
||||||
|
await expect(page.locator('svg.line-chart')).toBeVisible()
|
||||||
|
// Farbschlag-Verteilung enthält Agouti (mehrere aktive Agouti-Tiere im Seed)
|
||||||
|
await expect(page.locator('.hbar__label', { hasText: 'Agouti' })).toBeVisible()
|
||||||
|
})
|
||||||
80
gerbil-manager-web/e2e/tiere.spec.ts
Normal file
80
gerbil-manager-web/e2e/tiere.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.gerbils
|
||||||
|
const tabs = de.pages.tierTabs
|
||||||
|
|
||||||
|
test('Liste lädt und die Namenssuche filtert', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse')
|
||||||
|
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByPlaceholder(t.searchPlaceholder).first().fill('Krüm')
|
||||||
|
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.gerbils)
|
||||||
|
await page.getByRole('link', { name: t.newButton }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: t.form.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Pflichtfeld-Validierung auf Deutsch
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByText(t.validation.nameRequired)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.validation.genderRequired)).toBeVisible()
|
||||||
|
|
||||||
|
const name = uniqueName('Testmaus')
|
||||||
|
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||||
|
await page.getByLabel(`${t.fields.gender} *`).selectOption({ label: t.genderLabels.female })
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
|
||||||
|
// Erfolg: Detailseite des neuen Tieres
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.masterData)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Detailseite zeigt Stammdaten + Tab-Inhalte (Gesundheit/Gewicht/Fotos)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.masterData)).toBeVisible()
|
||||||
|
|
||||||
|
// Fotos (Standard-Tab): leerer Zustand + Datei-Pflicht-Validierung
|
||||||
|
await expect(page.getByText(tabs.photos.empty)).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tabs.photos.uploadButton }).click()
|
||||||
|
await expect(page.getByText(tabs.photos.validation.fileRequired)).toBeVisible()
|
||||||
|
|
||||||
|
// Gesundheit: Seed-Eintrag sichtbar, neuen Eintrag anlegen
|
||||||
|
await page.getByRole('tab', { name: t.detail.tabs.health }).click()
|
||||||
|
await expect(page.getByText('Jahresimpfung')).toBeVisible()
|
||||||
|
await expect(page.getByText(tabs.health.typeLabels.Vaccination, { exact: true })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: tabs.health.newButton }).click()
|
||||||
|
// 'Art' exakt ansteuern ('Tierarzt' enthält 'arzt'; Label-Text enthält die Optionen)
|
||||||
|
await page
|
||||||
|
.locator('label.field', { has: page.locator(`span:text-is("${tabs.health.fields.type}")`) })
|
||||||
|
.locator('select')
|
||||||
|
.selectOption({ label: tabs.health.typeLabels.Examination })
|
||||||
|
await page.getByLabel(`${tabs.health.fields.description} *`).fill('Routinekontrolle, alles gut')
|
||||||
|
await page.getByRole('button', { name: tabs.actions.save, exact: true }).click()
|
||||||
|
await expect(page.getByText('Routinekontrolle, alles gut')).toBeVisible()
|
||||||
|
|
||||||
|
// Gewicht: Verlaufskurve (2 Seed-Einträge) + Schnell-Erfassung
|
||||||
|
await page.getByRole('tab', { name: t.detail.tabs.weight }).click()
|
||||||
|
await expect(page.locator('svg.line-chart')).toBeVisible()
|
||||||
|
await expect(page.getByText(/78\s*g/)).toBeVisible()
|
||||||
|
await page.getByLabel(tabs.weight.fields.weightGrams).fill('85')
|
||||||
|
await page.getByRole('button', { name: tabs.weight.addButton, exact: true }).click()
|
||||||
|
await expect(page.getByText(/85\s*g/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Tier bearbeiten — Notizen ändern', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||||
|
await expect(page.getByRole('heading', { name: t.form.editTitle })).toBeVisible()
|
||||||
|
await page.getByLabel(t.fields.notes).fill('Sehr zutraulich.')
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
|
await expect(page.getByText('Sehr zutraulich.')).toBeVisible()
|
||||||
|
})
|
||||||
60
gerbil-manager-web/e2e/wuerfe.spec.ts
Normal file
60
gerbil-manager-web/e2e/wuerfe.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/** QA-1: Zucht (FEAT-3) — Wurf anlegen, Detail, Jungtier erfassen. */
|
||||||
|
import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||||
|
|
||||||
|
const t = de.pages.litters
|
||||||
|
const tg = de.pages.gerbils
|
||||||
|
|
||||||
|
test('Wurf anlegen — Abgabedatum wird mit +35 Tagen vorgeschlagen', async ({ page }) => {
|
||||||
|
await gotoSection(page, de.nav.litters)
|
||||||
|
await page.getByRole('link', { name: t.newButton }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: t.form.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Pflichtfeld-Validierung
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByText(t.validation.nameRequired)).toBeVisible()
|
||||||
|
await expect(page.getByText(t.validation.dateRequired)).toBeVisible()
|
||||||
|
|
||||||
|
const name = uniqueName('Wurf')
|
||||||
|
await page.getByLabel(`${t.fields.name} *`).fill(name)
|
||||||
|
await page.getByLabel(`${t.fields.date} *`).fill('2026-01-01')
|
||||||
|
// Vorschlag: Wurfdatum + 35 Tage
|
||||||
|
await expect(page.getByLabel(t.fields.expectedGoHomeDate)).toHaveValue('2026-02-05')
|
||||||
|
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
await expect(page.getByText(t.detail.parents)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Wurf-Detail zeigt Elterntiere und erwartete Farbschläge', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Fridolin' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Luna' })).toBeVisible()
|
||||||
|
// Beide Eltern haben Genotypen -> Farbvorhersage rendert
|
||||||
|
await expect(page.getByText(t.detail.expectedColors).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Jungtier erfassen — Formular ist mit Wurf + Geburtsdatum vorbelegt', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await page.getByRole('link', { name: t.detail.registerJuvenile }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: tg.form.createTitle })).toBeVisible()
|
||||||
|
|
||||||
|
// Vorbelegung aus ?litterId=&dob=
|
||||||
|
await expect(page.getByLabel(tg.fields.litter)).toHaveValue('w-kruemel')
|
||||||
|
await expect(page.getByLabel(tg.fields.dateOfBirth)).toHaveValue('2025-03-12')
|
||||||
|
|
||||||
|
const name = uniqueName('Jungtier')
|
||||||
|
await page.getByLabel(`${tg.fields.name} *`).fill(name)
|
||||||
|
await page.getByLabel(`${tg.fields.gender} *`).selectOption({ label: tg.genderLabels.male })
|
||||||
|
await page.getByRole('button', { name: tg.form.save, exact: true }).click()
|
||||||
|
await expect(page.getByRole('heading', { name })).toBeVisible()
|
||||||
|
|
||||||
|
// Das Jungtier hängt am Wurf
|
||||||
|
await expect(page.getByText('Wurf K')).toBeVisible()
|
||||||
|
|
||||||
|
// ... und erscheint auf der Wurf-Detailseite unter „Jungtiere“
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(name) })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|||||||
58
gerbil-manager-web/playwright.config.ts
Normal file
58
gerbil-manager-web/playwright.config.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* QA-1: App-weite E2E-Smoke-Suite (Playwright, getrennt von vitest).
|
||||||
|
*
|
||||||
|
* Zwei Betriebsarten:
|
||||||
|
* - MOCK (Standard): startet den Vite-Dev-Server auf :5199 und fängt alle
|
||||||
|
* API-Aufrufe per Route-Interception ab (Fixtures nach DATA-2-Vertrag,
|
||||||
|
* siehe e2e/mock-api.ts). Kein Backend nötig.
|
||||||
|
* - LIVE: `E2E_BASE_URL` zeigt auf das laufende Frontend (z. B. via Aspire
|
||||||
|
* AppHost); es wird NICHT gemockt und kein Dev-Server gestartet.
|
||||||
|
* Fixture-gebundene Tests überspringen sich dann selbst.
|
||||||
|
*
|
||||||
|
* Viewports: Smartphone (390px, mobile-first) und Laptop (1280px) — jede
|
||||||
|
* Spec läuft in beiden Projekten.
|
||||||
|
*/
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
const LIVE_BASE_URL = process.env.E2E_BASE_URL
|
||||||
|
const MOCK_PORT = 5199
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: [['list']],
|
||||||
|
timeout: 30_000,
|
||||||
|
use: {
|
||||||
|
baseURL: LIVE_BASE_URL ?? `http://localhost:${MOCK_PORT}`,
|
||||||
|
locale: 'de-DE',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'phone',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
hasTouch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'desktop',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: LIVE_BASE_URL
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: `npm run dev -- --port ${MOCK_PORT} --strictPort`,
|
||||||
|
url: `http://localhost:${MOCK_PORT}`,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import { configDefaults } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
@@ -10,4 +11,8 @@ export default defineConfig({
|
|||||||
// the port; this only widens the host from localhost to 0.0.0.0.
|
// the port; this only widens the host from localhost to 0.0.0.0.
|
||||||
host: true,
|
host: true,
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
// QA-1: e2e/ gehört Playwright (npm run e2e), nicht vitest.
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user