- 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>
355 lines
12 KiB
TypeScript
355 lines
12 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 JourneyItemRow from './JourneyItemRow.svelte';
|
|
|
|
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
|
|
id: 'item-1',
|
|
position: 0,
|
|
document: {
|
|
id: 'doc-1',
|
|
title: 'Brief von Karl',
|
|
datePrecision: 'DAY' as const,
|
|
receiverCount: 0
|
|
},
|
|
...overrides
|
|
});
|
|
|
|
const interludeItem = (note = 'Reise nach Wien') => ({
|
|
id: 'item-2',
|
|
position: 1,
|
|
note
|
|
});
|
|
|
|
const defaultProps = (overrides = {}) => ({
|
|
index: 0,
|
|
total: 3,
|
|
onMoveUp: vi.fn(),
|
|
onMoveDown: vi.fn(),
|
|
onRemove: vi.fn(),
|
|
onNotePatch: vi.fn().mockResolvedValue(undefined),
|
|
...overrides
|
|
});
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
describe('JourneyItemRow — interlude label', () => {
|
|
it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => {
|
|
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
|
|
|
await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument();
|
|
await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('uses "Zwischentext" in the move button aria-labels', async () => {
|
|
render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) });
|
|
|
|
await expect
|
|
.element(
|
|
page.getByRole('button', {
|
|
name: m.journey_move_up({ title: m.journey_interlude_label() })
|
|
})
|
|
)
|
|
.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — note textarea', () => {
|
|
it('opens note textarea on "Notiz hinzufügen" click', async () => {
|
|
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
|
|
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
|
|
|
await expect
|
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
it('blur without typing does not call onNotePatch and collapses the textarea', async () => {
|
|
// '' (untouched draft) and undefined (no note) both mean "no note" — a
|
|
// spurious PATCH {note: null} must not fire, and the empty textarea closes.
|
|
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
|
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
|
|
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
|
|
|
expect(onNotePatch).not.toHaveBeenCalled();
|
|
await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument();
|
|
});
|
|
|
|
it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => {
|
|
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
|
|
|
const toggle = page.getByText(m.journey_note_add());
|
|
expect(toggle.element().getAttribute('aria-expanded')).toBe('false');
|
|
await userEvent.click(toggle);
|
|
|
|
await vi.waitFor(() => {
|
|
const textarea = page
|
|
.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })
|
|
.element();
|
|
expect(document.activeElement).toBe(textarea);
|
|
});
|
|
});
|
|
|
|
it('calls onNotePatch on textarea blur with non-empty value', async () => {
|
|
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
|
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
|
|
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
|
await userEvent.fill(textarea, 'Eine neue Notiz');
|
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
|
|
|
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
|
|
});
|
|
|
|
it('limits the note textarea to 2000 characters', async () => {
|
|
render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() });
|
|
|
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
|
await expect.element(textarea).toHaveAttribute('maxlength', '2000');
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — note error state', () => {
|
|
it('shows role=alert error message when onNotePatch rejects', async () => {
|
|
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
|
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
|
|
|
await userEvent.click(page.getByText(m.journey_note_add()));
|
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
|
await userEvent.fill(textarea, 'Eine Notiz');
|
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
|
|
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — note remove error state', () => {
|
|
it('restores note and shows error when onNotePatch rejects during remove', async () => {
|
|
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'keep me' }),
|
|
...defaultProps({ onNotePatch })
|
|
});
|
|
|
|
await userEvent.click(page.getByText(m.journey_note_remove()));
|
|
|
|
// textarea should be visible again (showNote restored)
|
|
await expect
|
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
|
.toBeInTheDocument();
|
|
// error alert should be shown
|
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — interlude rules', () => {
|
|
it('does not show "Notiz entfernen" for interlude items', async () => {
|
|
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
|
|
|
// Note section should be visible (interlude always shows note)
|
|
await expect
|
|
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
|
.toBeInTheDocument();
|
|
// But "Notiz entfernen" must be absent
|
|
await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('blocks saving empty text on interlude note blur', async () => {
|
|
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
|
render(JourneyItemRow, {
|
|
item: interludeItem('original text'),
|
|
...defaultProps({ onNotePatch })
|
|
});
|
|
|
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
|
await userEvent.clear(textarea);
|
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
|
|
|
expect(onNotePatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores the original note text after a blocked empty-clear blur', async () => {
|
|
render(JourneyItemRow, {
|
|
item: interludeItem('original text'),
|
|
...defaultProps()
|
|
});
|
|
|
|
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
|
await userEvent.clear(textarea);
|
|
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
|
|
|
await expect.element(textarea).toHaveValue('original text');
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — remove confirm', () => {
|
|
it('shows inline confirm when removing a document item that has a note', async () => {
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Wichtige Notiz' }),
|
|
...defaultProps()
|
|
});
|
|
|
|
// Click remove (x button)
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
|
|
await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
|
|
await expect
|
|
.element(page.getByRole('button', { name: m.journey_remove_confirm_yes() }))
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
it('clicking Bestätigen invokes onRemove (destructive path)', async () => {
|
|
const onRemove = vi.fn();
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Wichtige Notiz' }),
|
|
...defaultProps({ onRemove })
|
|
});
|
|
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
|
|
|
|
expect(onRemove).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('confirm cancel restores remove button without calling onRemove', async () => {
|
|
const onRemove = vi.fn();
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Notiz' }),
|
|
...defaultProps({ onRemove })
|
|
});
|
|
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
|
|
|
|
expect(onRemove).not.toHaveBeenCalled();
|
|
// The remove button should be back
|
|
await expect
|
|
.element(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
)
|
|
.toBeInTheDocument();
|
|
});
|
|
|
|
it('confirm cancel returns keyboard focus to the row remove button', async () => {
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Notiz' }),
|
|
...defaultProps()
|
|
});
|
|
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
|
|
|
|
await vi.waitFor(() => {
|
|
const removeBtn = page
|
|
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
.element();
|
|
expect(document.activeElement).toBe(removeBtn);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — remove confirm a11y', () => {
|
|
it('confirm area is wrapped in role=group with an accessible label', async () => {
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Wichtige Notiz' }),
|
|
...defaultProps()
|
|
});
|
|
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
|
|
const group = document.querySelector('[role="group"]');
|
|
expect(group).toBeTruthy();
|
|
expect(group!.getAttribute('aria-label')).toBeTruthy();
|
|
});
|
|
|
|
it('keyboard focus moves to Cancel button when confirm appears', async () => {
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Wichtige Notiz' }),
|
|
...defaultProps()
|
|
});
|
|
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
|
|
await vi.waitFor(() => {
|
|
const cancelBtn = page
|
|
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
|
|
.element();
|
|
expect(document.activeElement).toBe(cancelBtn);
|
|
});
|
|
});
|
|
|
|
it('pressing Escape while confirm is open hides confirm and refocuses remove button', async () => {
|
|
render(JourneyItemRow, {
|
|
item: docItem({ note: 'Wichtige Notiz' }),
|
|
...defaultProps()
|
|
});
|
|
|
|
await userEvent.click(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
);
|
|
await vi.waitFor(() => {
|
|
const cancelBtn = page
|
|
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
|
|
.element();
|
|
expect(document.activeElement).toBe(cancelBtn);
|
|
});
|
|
|
|
await userEvent.keyboard('{Escape}');
|
|
|
|
await vi.waitFor(() => {
|
|
const removeBtn = page
|
|
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
.element();
|
|
expect(document.activeElement).toBe(removeBtn);
|
|
});
|
|
await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — pending remove state', () => {
|
|
it('renders dimmed with the pending text and without a remove button', async () => {
|
|
render(JourneyItemRow, {
|
|
item: docItem(),
|
|
...defaultProps({ pendingRemove: true })
|
|
});
|
|
|
|
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
|
|
await expect
|
|
.element(
|
|
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
|
|
)
|
|
.not.toBeInTheDocument();
|
|
|
|
const root = document.querySelector('[data-block-id="item-1"]')!;
|
|
expect(root.className).toContain('opacity-60');
|
|
});
|
|
});
|
|
|
|
describe('JourneyItemRow — drag handle', () => {
|
|
it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => {
|
|
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
|
|
|
const handle = document.querySelector('[data-drag-handle]')!;
|
|
expect(handle.getAttribute('tabindex')).toBe('-1');
|
|
expect(handle.getAttribute('aria-hidden')).toBe('true');
|
|
});
|
|
});
|