Files
familienarchiv/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts
Marcel f10b0cb73e 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>
2026-06-10 07:55:12 +02:00

198 lines
7.2 KiB
TypeScript

import { describe, it, expect, vi, expectTypeOf } from 'vitest';
import { createBlockDragDrop } from './useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/shared/types';
// ---------------------------------------------------------------------------
// Type-regression guard: createBlockDragDrop must accept any T extends {id: string}
// so JourneyEditor can reuse it without importing TranscriptionBlockData.
// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit
// until the function is made generic.
// ---------------------------------------------------------------------------
describe('createBlockDragDrop — generic type guard', () => {
it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => {
type SimpleItem = { id: string; position: number };
const items: SimpleItem[] = [
{ id: 'item-1', position: 0 },
{ id: 'item-2', position: 1 }
];
const onReorder = vi.fn();
const dd = createBlockDragDrop<SimpleItem>({ getSortedBlocks: () => items, onReorder });
// Verify the hook is functional with the new type — state reads must work
expect(dd.draggedBlockId).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => {
// If the generic constraint is wrong this line fails tsc --noEmit
expectTypeOf(createBlockDragDrop<TranscriptionBlockData>).toBeFunction();
// Runtime assertion so browser-mode doesn't report "no assertions"
expect(typeof createBlockDragDrop).toBe('function');
});
});
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return {
id,
annotationId: `ann-${id}`,
documentId: 'doc-1',
text: '',
label: null,
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
}
/**
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
* triggers handlePointerUp. Returns the onReorder spy.
*/
function simulateDragDrop(
dragId: string,
targetIdx: number,
blocks: TranscriptionBlockData[]
): ReturnType<typeof vi.fn> {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
// Build DOM
const listEl = document.createElement('div');
const wrappers = blocks.map(() => {
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
listEl.appendChild(wrapper);
return { grip, wrapper };
});
document.body.appendChild(listEl);
dd.setListElement(listEl);
// Mock bounding rects: each wrapper is 60px tall starting at y=0
wrappers.forEach(({ wrapper }, i) => {
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
top: i * 60,
height: 60,
bottom: (i + 1) * 60,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 60,
toJSON: () => ({})
} as DOMRect);
});
const dragIdx = blocks.findIndex((b) => b.id === dragId);
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
dragWrapper.setPointerCapture = vi.fn();
// Start drag
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, dragId);
// Move pointer to achieve the desired targetIdx
// midpoint of wrapper[i] = i*60 + 30
// clientY just before midpoint[i] → target = i
// clientY past last midpoint → target = wrappers.length
let clientY: number;
if (targetIdx <= 0) {
clientY = 5; // before first midpoint (30)
} else if (targetIdx >= wrappers.length) {
clientY = wrappers.length * 60 + 10; // past all midpoints
} else {
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
}
const moveEvent = new PointerEvent('pointermove', { clientY });
dd.handlePointerMove(moveEvent as PointerEvent);
dd.handlePointerUp();
document.body.removeChild(listEl);
return onReorder;
}
describe('createBlockDragDrop', () => {
it('initial state — no drag in progress', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
expect(dd.draggedBlockId).toBeNull();
expect(dd.dropTargetIdx).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('handleGripDown sets draggedBlockId when grip is hit', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
Object.defineProperty(e, 'target', { value: grip });
wrapper.setPointerCapture = vi.fn();
dd.handleGripDown(e as PointerEvent, 'block-1');
expect(dd.draggedBlockId).toBe('block-1');
document.body.removeChild(wrapper);
});
it('handlePointerUp without active drag is a no-op', () => {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
});
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
const onReorder = vi.fn();
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
wrapper.setPointerCapture = vi.fn();
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, 'b1');
// dropTargetIdx is still null (no pointer move happened)
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
expect(dd.draggedBlockId).toBeNull();
document.body.removeChild(wrapper);
});
it('reorder: moves block from index 0 to end', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b1', 3, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
});
it('reorder: moves block from end to index 0', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b3', 0, blocks);
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
});
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
const onReorder = simulateDragDrop('b1', 2, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
});
});