feat: add shared runtime input device layer
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-10 17:50:00 +08:00
parent 643161a168
commit 86fc382413
12 changed files with 1095 additions and 179 deletions

View File

@@ -0,0 +1,161 @@
import { describe, expect, test, vi } from 'vitest';
import {
createRuntimeDragInputController,
createRuntimeInputPointFromNormalized,
resolveRuntimeInputGridCell,
} from './index';
describe('runtime drag input controller', () => {
test('pointer-like short press remains a tap', () => {
const onTap = vi.fn();
const onDrop = vi.fn();
const controller = createRuntimeDragInputController({
dragThresholdPx: 12,
onTap,
onDrop,
});
controller.press({
targetId: 'piece-1',
inputId: 'pointer:1',
deviceKind: 'pointer',
point: { clientX: 10, clientY: 10 },
});
controller.move({
inputId: 'pointer:1',
point: { clientX: 14, clientY: 14 },
});
controller.release({
inputId: 'pointer:1',
point: { clientX: 14, clientY: 14 },
});
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTap).toHaveBeenCalledWith(
expect.objectContaining({ targetId: 'piece-1', dragging: false }),
);
expect(onDrop).not.toHaveBeenCalled();
});
test('device adapters can force continuous drag semantics', () => {
const onDragStart = vi.fn();
const onDragMove = vi.fn();
const onDrop = vi.fn();
const controller = createRuntimeDragInputController({
dragThresholdPx: 100,
onDragStart,
onDragMove,
onDrop,
});
controller.press({
targetId: 'piece-1',
inputId: 'mocap:hand',
deviceKind: 'mocap',
point: { clientX: 10, clientY: 10 },
});
controller.move({
inputId: 'mocap:hand',
point: { clientX: 11, clientY: 11 },
forceDragging: true,
});
controller.release({
inputId: 'mocap:hand',
point: { clientX: 12, clientY: 12 },
});
expect(onDragStart).toHaveBeenCalledTimes(1);
expect(onDragMove).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledWith(
expect.objectContaining({ deviceKind: 'mocap', dragging: true }),
);
});
test('device adapters can force drop on release without converting to tap', () => {
const onTap = vi.fn();
const onDrop = vi.fn();
const controller = createRuntimeDragInputController({
dragThresholdPx: 100,
onTap,
onDrop,
});
controller.press({
targetId: 'piece-1',
inputId: 'mocap:hand',
deviceKind: 'mocap',
point: { clientX: 10, clientY: 10 },
});
controller.release({
inputId: 'mocap:hand',
point: { clientX: 10, clientY: 10 },
forceDrop: true,
});
expect(onDrop).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledWith(
expect.objectContaining({
targetId: 'piece-1',
forceDrop: true,
dragging: false,
}),
);
expect(onTap).not.toHaveBeenCalled();
});
test('input-scoped cancel keeps unrelated active sessions alive', () => {
const onCancel = vi.fn();
const onDrop = vi.fn();
const controller = createRuntimeDragInputController({
dragThresholdPx: 1,
onCancel,
onDrop,
});
controller.press({
targetId: 'piece-1',
inputId: 'pointer:1',
deviceKind: 'pointer',
point: { clientX: 10, clientY: 10 },
});
controller.cancel('mocap:hand');
controller.move({
inputId: 'pointer:1',
point: { clientX: 20, clientY: 20 },
});
controller.release({
inputId: 'pointer:1',
point: { clientX: 20, clientY: 20 },
});
expect(onCancel).not.toHaveBeenCalled();
expect(onDrop).toHaveBeenCalledTimes(1);
expect(onDrop).toHaveBeenCalledWith(
expect.objectContaining({ inputId: 'pointer:1', targetId: 'piece-1' }),
);
});
});
describe('runtime input geometry', () => {
test('normalised device coordinates map into client coordinates and grid cells', () => {
const point = createRuntimeInputPointFromNormalized(0.75, 0.25, {
left: 20,
top: 10,
width: 200,
height: 100,
});
expect(point).toEqual({
clientX: 170,
clientY: 35,
normalizedX: 0.75,
normalizedY: 0.25,
});
expect(resolveRuntimeInputGridCell(point, { rows: 4, cols: 4 })).toEqual({
row: 1,
col: 3,
});
});
});