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'); }); });