- 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>
814 lines
27 KiB
TypeScript
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();
|
|
});
|
|
});
|