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:
2026-06-22 22:38:23 +02:00
parent b9021fe577
commit dcf0e40f9b
18 changed files with 2592 additions and 154 deletions

View File

@@ -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')