fix(journey): editor review round — labels, errors, pending state, a11y, tests
Addresses the remaining #792 review blockers and concerns in the journey editor cluster: - Interlude rows show 'Zwischentext' (dedicated key), not the add-button text - All four mutation handlers route the backend ErrorCode through getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden') and console.error their failures so client-side errors leave a trace - Remove implements the spec'd pending state: row stays dimmed with an aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row - Move announcements fire after the reorder resolves (no false 'verschoben') - Touch targets ≥44px (remove ×, note links, create submit); focus moves to the new row after add, to a sensible neighbor after remove, back to × on confirm-cancel; drag handle is pointer-only; title/intro get aria-labels; publish-disabled reason is a visible hint, not a title tooltip - Amber warning styles use new --color-warning-* tokens with dark remaps - Blocked interlude-clear restores the draft instead of showing phantom text - useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports another domain's internals - Test hardening: reorder-failure rollback (non-ok + reject), publish/ unpublish/empty-warning surface, destructive confirm path, maxlength assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch, fixture factory, m.* assertions, all fixed sleeps replaced with polling 67 component tests green across 6 spec files; transcription consumer of the moved hook re-verified (30 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,40 @@
|
||||
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';
|
||||
|
||||
const docSummary = (id: string, title: string) => ({
|
||||
id,
|
||||
title,
|
||||
datePrecision: 'DAY' as const
|
||||
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> = {}) => ({
|
||||
@@ -51,14 +79,19 @@ describe('JourneyEditor — empty state', () => {
|
||||
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('publish button disabled when no items', async () => {
|
||||
it('labels the title input and intro textarea for screen readers', async () => {
|
||||
render(JourneyEditor, defaultProps());
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
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('Diese Lesereise ist noch leer.')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +110,17 @@ describe('JourneyEditor — items in position order', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — publish disabled when title empty', () => {
|
||||
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,
|
||||
@@ -96,107 +139,6 @@ describe('JourneyEditor — publish disabled when title empty', () => {
|
||||
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
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') };
|
||||
mockCsrfFetch(() => newItem);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
// picker search results
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'Brief von Karl',
|
||||
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'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
// POST /items
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(newItem)
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps());
|
||||
|
||||
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
await new Promise((r) => setTimeout(r, 350)); // wait debounce
|
||||
await userEvent.click(page.getByText(/Brief von Karl/));
|
||||
|
||||
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('Zwischentext hinzufügen'));
|
||||
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: 'Reise nach Wien' })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyEditor — remove with rollback', () => {
|
||||
it('restores item 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: 'Eintrag entfernen' }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Item should be restored after rollback
|
||||
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('item-add enables publish button (isDirty stays false, canPublish becomes true)', async () => {
|
||||
const newItem = { id: 'i1', position: 0, note: 'Test' };
|
||||
@@ -208,14 +150,248 @@ describe('JourneyEditor — remove with rollback', () => {
|
||||
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
|
||||
|
||||
// Add interlude
|
||||
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
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() }));
|
||||
|
||||
// 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() }));
|
||||
|
||||
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() }));
|
||||
|
||||
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() }));
|
||||
|
||||
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', () => {
|
||||
@@ -238,15 +414,16 @@ describe('JourneyEditor — reorder via move buttons', () => {
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
await new Promise((r) => setTimeout(r, 50)); // handleMoveUp → handleReorder → csrfFetch: two await levels
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
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 () => {
|
||||
@@ -268,20 +445,61 @@ describe('JourneyEditor — reorder via move buttons', () => {
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
|
||||
await new Promise((r) => setTimeout(r, 50)); // handleMoveDown → handleReorder → csrfFetch: two await levels
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/reorder'),
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ itemIds: ['i2', 'i1'] })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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('clears the live announce region 500ms after a move operation', async () => {
|
||||
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') }
|
||||
@@ -300,13 +518,38 @@ describe('JourneyEditor — live announce region', () => {
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
|
||||
await new Promise((r) => setTimeout(r, 50)); // wait for csrfFetch
|
||||
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
await vi.waitFor(() => {
|
||||
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 650)); // 500ms clear timeout + buffer
|
||||
expect((liveRegion?.textContent ?? '').trim()).toBe('');
|
||||
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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,15 +573,16 @@ describe('JourneyEditor — note patch body', () => {
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/i1'),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: null })
|
||||
})
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/items/i1'),
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ note: null })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -349,44 +593,17 @@ describe('JourneyEditor — duplicate document aria-disabled', () => {
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'd1',
|
||||
title: 'Brief von Karl',
|
||||
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'
|
||||
}
|
||||
]
|
||||
})
|
||||
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
|
||||
})
|
||||
);
|
||||
|
||||
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
|
||||
|
||||
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||
await userEvent.click(page.getByText(m.journey_add_document()));
|
||||
await userEvent.fill(page.getByRole('combobox'), 'Karl');
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user