feat(tickets): Rückfragen-Dialog, Verlinkungen, Entwurfssicherung + PWA-Vorbereitung
- Feedback: fixNote (Changelog), agentContext (intern), thread (Q&A-Verlauf); 3 gefilterte Ansichten (Rückfragen/Offen/Geschlossen) - Tickets-Text: klickbare Tier-Links (ID→Name), Ticket→Ticket-Links (?focus=), Markdown-Links; Antwort-Entwurf pro Ticket in localStorage - "Fehler melden": Entwurf übersteht App-Wechsel/Schließen (ein gemeinsamer Entwurf) - entityName als Hyperlink zum referenzierten Datensatz Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,27 @@ test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg',
|
||||
expect(typeof report!.clientTimestamp).toBe('string')
|
||||
})
|
||||
|
||||
test('Entwurf bleibt nach Schließen + Navigation + Wiederöffnen erhalten', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Auf einer Tier-Seite anfangen zu schreiben …
|
||||
await page.goto('/rennmaeuse/kruemel')
|
||||
await page.getByRole('button', { name: f.button }).click()
|
||||
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||
await dialog.getByLabel(f.label).fill('Halb fertiger Text, will kurz was nachschauen …')
|
||||
|
||||
// … Fenster (versehentlich) schließen …
|
||||
await dialog.getByRole('button', { name: f.close }).click()
|
||||
await expect(dialog).not.toBeVisible()
|
||||
|
||||
// … in der App woanders hin navigieren (anderer Kontext) …
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
|
||||
// … „Fehler melden" erneut öffnen: der Text ist noch da.
|
||||
await page.getByRole('button', { name: f.button }).click()
|
||||
const dialog2 = page.getByRole('dialog', { name: f.dialogTitle })
|
||||
await expect(dialog2.getByLabel(f.label)).toHaveValue('Halb fertiger Text, will kurz was nachschauen …')
|
||||
})
|
||||
|
||||
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/wuerfe/w-kruemel')
|
||||
|
||||
@@ -412,6 +412,9 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
question: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
@@ -433,19 +436,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
status?: string
|
||||
question?: string
|
||||
answer?: string
|
||||
fixNote?: string
|
||||
agentContext?: string
|
||||
}
|
||||
const row = db.feedback[idx]
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
}
|
||||
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved).
|
||||
// Rückfrage anhängen → NeedsInfo (außer bereits Resolved). Eine vorhandene
|
||||
// Frage/Antwort-Runde wird zuerst in den Verlauf (thread) verschoben.
|
||||
if (typeof body.question === 'string') {
|
||||
const q = body.question.trim()
|
||||
row.question = q.length === 0 ? null : q
|
||||
if (row.question && row.status !== 'Resolved') {
|
||||
row.status = 'NeedsInfo'
|
||||
row.resolvedAt = null
|
||||
if (q.length > 0) {
|
||||
const thread = (row.thread as Record<string, unknown>[] | undefined) ?? []
|
||||
if (typeof row.question === 'string' && row.question)
|
||||
thread.push({ role: 'maintainer', text: row.question, at: row.createdAt ?? null })
|
||||
if (typeof row.answer === 'string' && row.answer)
|
||||
thread.push({ role: 'breeder', text: row.answer, at: row.answeredAt ?? null })
|
||||
row.thread = thread
|
||||
row.answer = null
|
||||
row.answeredAt = null
|
||||
row.question = q
|
||||
if (row.status !== 'Resolved') {
|
||||
row.status = 'NeedsInfo'
|
||||
row.resolvedAt = null
|
||||
}
|
||||
} else {
|
||||
row.question = null
|
||||
}
|
||||
}
|
||||
// Antwort der Züchterin → Answered + answeredAt.
|
||||
@@ -471,6 +489,16 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
: 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
}
|
||||
// Changelog (fixNote) — laienverständlich, sichtbar.
|
||||
if (typeof body.fixNote === 'string') {
|
||||
const note = body.fixNote.trim()
|
||||
row.fixNote = note.length === 0 ? null : note
|
||||
}
|
||||
// Internes Agenten-Arbeitsgedächtnis — ändert den Status nicht.
|
||||
if (typeof body.agentContext === 'string') {
|
||||
const c = body.agentContext.trim()
|
||||
row.agentContext = c.length === 0 ? null : c
|
||||
}
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
|
||||
@@ -411,9 +411,15 @@ export function seedDb(): MockDb {
|
||||
question: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
// Changelog (laienverständlich) für die geschlossene Ansicht.
|
||||
fixNote: 'Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.',
|
||||
// INTERN: darf NIE in der Züchterin-UI erscheinen.
|
||||
agentContext: 'GEHEIM-AGENT-CONTEXT: motherId via conflict-decisions.json gesetzt, re-ingest.',
|
||||
thread: [],
|
||||
},
|
||||
{
|
||||
// Ticket mit offener Rückfrage (NeedsInfo): die Züchterin soll hier antworten können.
|
||||
// Hat bereits eine frühere, beantwortete Runde im Verlauf (thread).
|
||||
id: 'feedback-needsinfo',
|
||||
message: 'Der Wurf B hat ein falsches Geburtsdatum.',
|
||||
context: 'litter-detail',
|
||||
@@ -430,6 +436,13 @@ export function seedDb(): MockDb {
|
||||
question: 'Welches Geburtsdatum ist korrekt — der 1. oder der 2. Juni?',
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
fixNote: null,
|
||||
agentContext: 'GEHEIM-AGENT-CONTEXT: warte auf DOB-Bestätigung für Wurf B.',
|
||||
// Frühere, abgeschlossene Runde (älteste zuerst) — bleibt im Verlauf erhalten.
|
||||
thread: [
|
||||
{ role: 'maintainer', text: 'Wurde der Wurf im Mai oder Juni geboren?', at: '2026-06-13T08:00:00Z' },
|
||||
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'feedback-open',
|
||||
@@ -448,6 +461,9 @@ export function seedDb(): MockDb {
|
||||
question: null,
|
||||
answer: null,
|
||||
answeredAt: null,
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,28 +1,99 @@
|
||||
/**
|
||||
* FEEDBACK-TICKETS: "Meine Tickets" — Verwaltung der eingereichten Fehlerberichte.
|
||||
* Erreichbar über Hilfe → „Meine Tickets". Liste mit Status-Badges (Offen/Gelöst),
|
||||
* gelöst markieren / wieder öffnen, Nachricht bearbeiten, löschen.
|
||||
* Erreichbar über Hilfe → „Meine Tickets". Drei gefilterte Verlinkungen (Filter-Chips):
|
||||
* „Rückfragen" (im Dialog), „Offen", „Geschlossen" (zeigt den Changelog/fixNote).
|
||||
* Pro Ticket: gelöst markieren / wieder öffnen, Nachricht bearbeiten, antworten, löschen,
|
||||
* Frage/Antwort-Verlauf (thread). Das interne `agentContext` wird NIE gerendert.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, acceptNextDialog, test } from './fixtures'
|
||||
|
||||
const tt = de.feedback.tickets
|
||||
|
||||
/** In die gewünschte gefilterte Ansicht wechseln (Filter-Chip klicken). */
|
||||
async function selectView(page: import('@playwright/test').Page, label: string) {
|
||||
await page.locator('.tickets-filter').filter({ hasText: label }).click()
|
||||
}
|
||||
|
||||
test.describe('Meine Tickets', () => {
|
||||
test.beforeEach(() => skipUnlessMock())
|
||||
|
||||
test('über Hilfe erreichbar, zeigt offenes und gelöstes Ticket mit Badge', async ({ page }) => {
|
||||
test('über Hilfe erreichbar, Default-Ansicht „Offen" zeigt offenes Ticket mit Badge', async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoSection(page, de.nav.help)
|
||||
await page.getByRole('link', { name: tt.link }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: tt.title })).toBeVisible()
|
||||
|
||||
// Offenes Ticket (Badge „Offen") + dessen Nachricht.
|
||||
// Default = „Offen": offenes Ticket + dessen Nachricht sichtbar.
|
||||
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await expect(page.getByText(tt.statusOpen, { exact: true }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
// Gelöstes Ticket (Badge mit Häkchen + „Gelöst").
|
||||
test('drei gefilterte Verlinkungen filtern korrekt, Anzahl-Badges stimmen', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
// Filter-Chips vorhanden, jeweils mit Anzahl-Badge (1 pro Gruppe in den Seed-Daten).
|
||||
const dialog = page.locator('.tickets-filter').filter({ hasText: tt.filters.dialog })
|
||||
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
|
||||
const closed = page.locator('.tickets-filter').filter({ hasText: tt.filters.closed })
|
||||
await expect(dialog.locator('.tickets-filter__count')).toHaveText('1')
|
||||
await expect(open.locator('.tickets-filter__count')).toHaveText('1')
|
||||
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
|
||||
|
||||
// „Offen": nur das offene Ticket.
|
||||
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
|
||||
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toHaveCount(0)
|
||||
|
||||
// „Rückfragen": nur das Dialog-Ticket (NeedsInfo).
|
||||
await selectView(page, tt.filters.dialog)
|
||||
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toBeVisible()
|
||||
await expect(page.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toHaveCount(0)
|
||||
|
||||
// „Geschlossen": nur das gelöste Ticket.
|
||||
await selectView(page, tt.filters.closed)
|
||||
await expect(page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.')).toBeVisible()
|
||||
await expect(page.getByText(tt.statusResolved, { exact: false }).first()).toBeVisible()
|
||||
await expect(page.getByText('Der Wurf B hat ein falsches Geburtsdatum.')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('geschlossenes Ticket zeigt den Changelog (fixNote) + Datum', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card.getByText(tt.changelogLabel)).toBeVisible()
|
||||
await expect(
|
||||
card.getByText('Der Vater von Krümel war falsch — er ist jetzt Balu. Bitte den Stammbaum neu laden.'),
|
||||
).toBeVisible()
|
||||
// Datum „Gelöst am" in der Meta-Leiste.
|
||||
await expect(card.getByText(tt.resolvedLabel)).toBeVisible()
|
||||
})
|
||||
|
||||
test('agentContext wird NIE in der Züchterin-UI gerendert (in allen Ansichten)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
// In jeder Ansicht prüfen: der interne Kontext-Text taucht nirgends auf.
|
||||
for (const view of [tt.filters.open, tt.filters.dialog, tt.filters.closed]) {
|
||||
await selectView(page, view)
|
||||
await expect(page.getByText('GEHEIM-AGENT-CONTEXT', { exact: false })).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Verlauf (thread) früherer Runden wird angezeigt', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.dialog)
|
||||
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
|
||||
await expect(card.getByText(tt.threadLabel)).toBeVisible()
|
||||
// Frühere Runde (Frage + Antwort) sichtbar.
|
||||
await expect(card.getByText('Wurde der Wurf im Mai oder Juni geboren?')).toBeVisible()
|
||||
await expect(card.getByText('Im Juni.')).toBeVisible()
|
||||
// Aktuelle offene Rückfrage ebenfalls sichtbar.
|
||||
await expect(card.getByText('Welches Geburtsdatum ist korrekt')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket als gelöst markieren', async ({ page }) => {
|
||||
@@ -35,17 +106,20 @@ test.describe('Meine Tickets', () => {
|
||||
|
||||
await openCard.getByRole('button', { name: tt.markResolved }).click()
|
||||
|
||||
// Badge wechselt auf „Gelöst", Aktion bietet jetzt „Wieder öffnen".
|
||||
// Auf das Badge (.ticket-badge) zielen — „Gelöst" steht sonst auch im Meta-Label „Gelöst am".
|
||||
await expect(openCard.locator('.ticket-badge--resolved')).toBeVisible()
|
||||
await expect(openCard.getByRole('button', { name: tt.reopen })).toBeVisible()
|
||||
// Das Ticket verlässt die „Offen"-Ansicht — die Ansicht weicht automatisch auf
|
||||
// eine nicht-leere Gruppe aus. Das gelöste Ticket findet sich unter „Geschlossen".
|
||||
await selectView(page, tt.filters.closed)
|
||||
const resolvedCard = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.' })
|
||||
await expect(resolvedCard.locator('.ticket-badge--resolved')).toBeVisible()
|
||||
await expect(resolvedCard.getByRole('button', { name: tt.reopen })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Nachricht eines Tickets bearbeiten', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
|
||||
// Karte über den Datensatz-Namen (bleibt auch im Bearbeiten-Modus sichtbar)
|
||||
// adressieren — die Nachricht selbst wird beim Bearbeiten durch das Textfeld ersetzt.
|
||||
// „Offen"-Ansicht (Default): das offene Ticket über den Datensatz-Namen adressieren.
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(card.getByText('Das Gewicht wird auf der Verlaufskurve falsch gerundet.')).toBeVisible()
|
||||
await card.getByRole('button', { name: tt.edit }).click()
|
||||
@@ -61,6 +135,7 @@ test.describe('Meine Tickets', () => {
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.dialog)
|
||||
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Wurf B' })
|
||||
await expect(card).toBeVisible()
|
||||
@@ -70,10 +145,10 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(card.getByText(tt.statusNeedsInfo, { exact: true })).toBeVisible()
|
||||
|
||||
// Antworten.
|
||||
await card.locator('textarea').fill('Korrekt ist der 2. Juni.')
|
||||
await card.locator('.ticket-card__answer-form textarea').fill('Korrekt ist der 2. Juni.')
|
||||
await card.getByRole('button', { name: tt.answerButton }).click()
|
||||
|
||||
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet".
|
||||
// Antwort wird angezeigt + Badge wechselt auf „Beantwortet" (bleibt in der Dialog-Ansicht).
|
||||
await expect(card.getByText('Korrekt ist der 2. Juni.')).toBeVisible()
|
||||
await expect(card.locator('.ticket-badge--answered')).toBeVisible()
|
||||
await expect(card.getByText(tt.statusAnswered, { exact: true })).toBeVisible()
|
||||
@@ -81,6 +156,7 @@ test.describe('Meine Tickets', () => {
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
|
||||
Reference in New Issue
Block a user