Files
familienarchiv/frontend/src/lib/geschichte/JourneyEditor.svelte.spec.ts
Marcel 8995b6e922 fix(journey-editor): note UX, error codes, list semantics, a11y labels
- handleNotePatch routes failures through failureMessage() so a backend
  JOURNEY_NOTE_TOO_LONG renders its specific message in the row
- handleNoteBlur: '' vs undefined no-op guard (no spurious PATCH
  {note:null}), empty blur collapses the textarea, clearing an existing
  note collapses after the PATCH lands (spec LE-3)
- 'Notiz hinzufügen' toggle gets aria-expanded and moves focus into the
  revealed textarea
- journey_remove_item_aria interpolates the item title (de/en/es); dead
  journey_drag_aria_label key deleted from all locales
- editor item list is an <ol> (screen readers announce the ordering)
- editor title input gets maxlength=255 + border-danger error cue; intro
  textarea gets maxlength=4000
- Briefmeta line (date · von X an Y) under document titles in the editor
  row via the shared formatDocumentMetaLine (spec LE-2)
- new specs: successful save clears the unsaved warning; item add does
  not arm the guard; server-confirmed order after successful reorder;
  blur-without-typing no-op; focus hand-off into the note textarea

Review round 3: Sara (1-3), Felix, Elicit (LE-2/LE-3), Leonie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:28:38 +02:00

814 lines
27 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyEditor from './JourneyEditor.svelte';
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate } from '$app/navigation';
const docSummary = (id: string, title: string) => ({
id,
title,
datePrecision: 'DAY' as const,
receiverCount: 0
});
/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
});
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Briefe der Familie Raddatz',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
});
const defaultProps = (overrides: Record<string, unknown> = {}) => ({
geschichte: makeGeschichte(),
onSubmit: vi.fn().mockResolvedValue(undefined),
submitting: false,
...overrides
});
function mockCsrfFetch(responseFactory: () => object) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(responseFactory())
})
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyEditor — empty state', () => {
it('renders title input and intro textarea', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByPlaceholder(/Titel/)).toBeInTheDocument();
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
});
it('labels the title input and intro textarea for screen readers', async () => {
render(JourneyEditor, defaultProps());
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
await expect
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
.toBeInTheDocument();
});
it('shows empty state message when items list is empty', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
});
});
describe('JourneyEditor — items in position order', () => {
it('renders items sorted by position', async () => {
const items = [
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }
];
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Brief A (position 0) must appear before Brief B (position 1) in DOM order
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe('JourneyEditor — publish surface', () => {
it('publish button disabled when no items', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
});
it('shows a visible hint while publishing is disabled', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
});
it('publish stays disabled until title is non-empty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
title: '',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
const titleInput = page.getByPlaceholder(/Titel/);
await userEvent.fill(titleInput, 'Meine Reise');
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
it('adding an item enables the publish button (canPublish becomes true)', async () => {
const newItem = { id: 'i1', position: 0, note: 'Test' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
// Publish should be disabled before adding item
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
// Add interlude
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// After item add, publish becomes enabled — item was added and state is correct
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
});
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: ' Meine Reise ',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
);
});
});
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
status: 'PUBLISHED',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
});
});
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
render(
JourneyEditor,
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
);
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
});
});
describe('JourneyEditor — add document', () => {
it('calls POST with documentId when document selected from picker', async () => {
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// dropdown option appears after the typeahead debounce
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
await userEvent.click(page.getByText(/Brief von Karl ·/));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({ method: 'POST' })
);
});
});
});
describe('JourneyEditor — add interlude', () => {
it('calls POST with note on interlude confirm', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
it('moves keyboard focus into the new row after the interlude is added', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
});
});
});
describe('JourneyEditor — mutation error code routing', () => {
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await expect
.element(page.getByText(m.error_journey_document_already_added()))
.toBeInTheDocument();
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
});
});
describe('JourneyEditor — remove with pending state', () => {
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
let resolveFetch!: (value: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
// Row still present, marked as pending (text appears in the row AND the live region,
// so scope the query to the row instead of using a page-wide locator)
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
await vi.waitFor(() => {
const row = document.querySelector('[data-block-id="i1"]');
expect(row).toBeTruthy();
expect(row!.textContent).toContain(m.journey_item_pending_remove());
expect(row!.className).toContain('opacity-60');
});
resolveFetch({ ok: true });
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('removes the row on successful DELETE', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('focuses a sensible target after a successful remove (not body)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
await vi.waitFor(() => {
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
});
});
});
describe('JourneyEditor — reorder via move buttons', () => {
it('move-up calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
it('move-down calls PUT reorder with swapped IDs', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'),
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
})
);
});
});
it('renders the server-confirmed order after a successful reorder', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
const briefB = page.getByText('Brief B').element();
const briefA = page.getByText('Brief A').element();
expect(
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
});
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('JourneyEditor — live announce region', () => {
it('announces the move only after the reorder resolved, then clears', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i2', position: 0, document: docSummary('d2', 'Brief B') },
{ id: 'i1', position: 1, document: docSummary('d1', 'Brief A') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
});
await vi.waitFor(
() => {
expect((liveRegion?.textContent ?? '').trim()).toBe('');
},
{ timeout: 2000 }
);
});
it('announces the error text instead of a success message when the move fails', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
});
});
});
describe('JourneyEditor — note patch body', () => {
it('sends {"note":null} when note textarea is cleared and blurred', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A'), note: 'old note' }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi
.fn()
.mockResolvedValue({ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'),
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ note: null })
})
);
});
});
});
describe('JourneyEditor — duplicate document aria-disabled', () => {
it('already-added document appears as aria-disabled in picker', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
const option = page
.getByText(/Brief von Karl ·/)
.element()
.closest('li')!;
expect(option.getAttribute('aria-disabled')).toBe('true');
});
});
describe('JourneyEditor — unsaved warning banner', () => {
function triggerNavigationAttempt() {
const calls = vi.mocked(beforeNavigate).mock.calls;
if (calls.length === 0) return;
const [callback] = calls[calls.length - 1];
const cancel = vi.fn();
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
cancel,
to: { url: new URL('http://localhost/geschichten') }
});
return cancel;
}
it('banner is absent before any edit or navigation attempt', async () => {
render(JourneyEditor, defaultProps());
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('banner appears when dirty and a navigation is attempted', async () => {
render(JourneyEditor, defaultProps());
// Mark dirty by editing the title
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Simulate the user trying to navigate away
const cancel = triggerNavigationAttempt();
expect(cancel).toHaveBeenCalled();
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
});
it('banner stays after a failed save (clearOnSuccess not called when onSubmit throws)', async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error('server error'));
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: 'Titel',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Trigger navigation → banner appears
triggerNavigationAttempt();
await expect.element(page.getByText(m.admin_unsaved_warning())).toBeInTheDocument();
// Attempt save — onSubmit throws
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
// Banner must still be visible (isDirty was not cleared)
await vi.waitFor(() => {
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
});
});
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
// Regression guard for clearOnSuccess(): without it, a curator who edits the
// title and saves successfully stays trapped — the page goto() gets cancelled
// by the still-armed guard and the banner appears after a *successful* save.
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(JourneyEditor, defaultProps({ onSubmit }));
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Dirty state blocks navigation
expect(triggerNavigationAttempt()).toHaveBeenCalled();
// Save succeeds
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
// Guard is disarmed again — navigation passes and no banner shows
await vi.waitFor(() => {
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// The new interlude row renders its note textarea once the POST resolved
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// The item was persisted by its own POST — navigating away loses nothing
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
});
describe('JourneyEditor — selectedPersons marks dirty', () => {
function getNavCallback() {
const calls = vi.mocked(beforeNavigate).mock.calls;
const [callback] = calls[calls.length - 1];
return (cancel = vi.fn()) => {
(callback as (nav: { cancel: () => void; to: { url: URL } | null }) => void)({
cancel,
to: { url: new URL('http://localhost/geschichten') }
});
return cancel;
};
}
it('removing a person chip marks the editor dirty', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
})
})
);
// Confirm navigation is NOT blocked initially (clean state)
const triggerNav = getNavCallback();
expect(triggerNav()).not.toHaveBeenCalled();
// Remove the person chip (aria-label = m.comp_multiselect_remove() = "Entfernen")
await userEvent.click(page.getByRole('button', { name: m.comp_multiselect_remove() }));
// After person removal, navigation should be blocked
await vi.waitFor(() => {
const cancel = triggerNav();
expect(cancel).toHaveBeenCalled();
});
});
});
describe('JourneyEditor — person chips from GeschichteView', () => {
it('renders person names in the sidebar chips (PersonView carries no displayName)', async () => {
render(
JourneyEditor,
defaultProps({
geschichte: makeGeschichte({
persons: [{ id: 'p1', firstName: 'Anna', lastName: 'Schmidt' }]
})
})
);
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
});