Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

156 lines
5.7 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import type { Slide, PPTElement } from '@openmaic/dsl';
import { useEditGesture, type UseEditGestureArgs } from '../../src/editing/useEditGesture';
const baseElement = {
id: 'a',
type: 'text',
left: 100,
top: 100,
width: 200,
height: 80,
rotate: 0,
content: 'x',
defaultFontName: 'a',
defaultColor: '#000',
lineHeight: 1,
};
function makeSlide(overrides: Record<string, unknown> = {}): Slide {
return {
id: 's',
viewportSize: 1000,
viewportRatio: 0.5625,
elements: [{ ...baseElement, ...overrides }],
} as unknown as Slide;
}
/**
* Minimal harness: wires a single element's `onElementPointerDown` to a real
* DOM node so `fireEvent.pointer*` (which dispatches real PointerEvents that
* bubble to `window`, exactly like the production hit-target div) exercises
* `useEditGesture` exactly as `EditableSlideCanvas` does — but directly, so a
* pointer-down can be armed on a locked element even without a rendered hit
* target (defense-in-depth: the hook must refuse on its own).
*/
function Harness(props: UseEditGestureArgs & { targetEl: PPTElement }) {
const { targetEl, ...args } = props;
const { onElementPointerDown } = useEditGesture(args);
return <div data-testid="hit" onPointerDown={(e) => onElementPointerDown(targetEl, e)} />;
}
describe('useEditGesture — locked element (defense-in-depth)', () => {
it('onElementPointerDown ignores a pointer-down on a locked element: no gesture arms', () => {
const lockedSlide = makeSlide({ lock: true });
const el = lockedSlide.elements[0];
const onElementsChange = vi.fn();
const onSelectionChange = vi.fn();
const { container } = render(
<Harness
slide={lockedSlide}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSelectionChange}
onElementsChange={onElementsChange}
targetEl={el}
/>,
);
const hit = container.querySelector('[data-testid="hit"]') as HTMLElement;
fireEvent.pointerDown(hit, { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit, { pointerId: 1, clientX: 40, clientY: 40 });
fireEvent.pointerUp(hit, { pointerId: 1, clientX: 40, clientY: 40 });
// No gesture ever armed, so pointer-up (even past the drag threshold)
// must emit neither a mutation nor a selection change.
expect(onElementsChange).not.toHaveBeenCalled();
expect(onSelectionChange).not.toHaveBeenCalled();
});
it('dragging a not-currently-selected element selects it (on pointer-down) AND emits exactly one element.update', () => {
// R1: a drag must leave the dragged element selected in the controlled
// `selection`. Starting from an empty selection, pointer-down selects the
// element and pointer-up (past the drag threshold) emits its move — one of
// each, no double-emit.
const unlockedSlide = makeSlide();
const el = unlockedSlide.elements[0];
const onElementsChange = vi.fn();
const onSelectionChange = vi.fn();
const { container } = render(
<Harness
slide={unlockedSlide}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSelectionChange}
onElementsChange={onElementsChange}
targetEl={el}
/>,
);
const hit = container.querySelector('[data-testid="hit"]') as HTMLElement;
fireEvent.pointerDown(hit, { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit, { pointerId: 1, clientX: 40, clientY: 40 });
fireEvent.pointerUp(hit, { pointerId: 1, clientX: 40, clientY: 40 });
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(onSelectionChange).toHaveBeenCalledWith({ elementIds: ['a'], primaryId: 'a' });
expect(onElementsChange).toHaveBeenCalledTimes(1);
expect(onElementsChange.mock.calls[0][0]).toEqual([
{ type: 'element.update', id: 'a', props: { left: 140, top: 140 } },
]);
});
it('does NOT re-select an already-sole-primary element on interaction (no redundant emit)', () => {
// R1: if the element is already the sole/primary selection, pointer-down
// must not emit a redundant selection change.
const unlockedSlide = makeSlide();
const el = unlockedSlide.elements[0];
const onSelectionChange = vi.fn();
const { container } = render(
<Harness
slide={unlockedSlide}
scale={1}
selection={{ elementIds: ['a'], primaryId: 'a' }}
onSelectionChange={onSelectionChange}
onElementsChange={vi.fn()}
targetEl={el}
/>,
);
const hit = container.querySelector('[data-testid="hit"]') as HTMLElement;
// Plain click on the already-selected element: no selection emit.
fireEvent.pointerDown(hit, { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerUp(hit, { pointerId: 1, clientX: 0, clientY: 0 });
expect(onSelectionChange).not.toHaveBeenCalled();
});
it('control: an unlocked element still arms normally (same inputs, no lock)', () => {
const unlockedSlide = makeSlide();
const el = unlockedSlide.elements[0];
const onElementsChange = vi.fn();
const { container } = render(
<Harness
slide={unlockedSlide}
scale={1}
selection={{ elementIds: [] }}
onElementsChange={onElementsChange}
targetEl={el}
/>,
);
const hit = container.querySelector('[data-testid="hit"]') as HTMLElement;
fireEvent.pointerDown(hit, { pointerId: 1, clientX: 0, clientY: 0 });
fireEvent.pointerMove(hit, { pointerId: 1, clientX: 40, clientY: 40 });
fireEvent.pointerUp(hit, { pointerId: 1, clientX: 40, clientY: 40 });
expect(onElementsChange).toHaveBeenCalledTimes(1);
});
});