feat: add child motion entry and fix auth env
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-10 18:27:51 +08:00
parent 86fc382413
commit 46a254f142
22 changed files with 2868 additions and 58 deletions

View File

@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
type ChildMotionDebugAction,
createChildMotionDebugInputController,
resolveKeyboardDebugAction,
} from './childMotionDebugInput';
let mountedTargets: HTMLElement[] = [];
afterEach(() => {
mountedTargets.forEach((target) => target.remove());
mountedTargets = [];
});
function createTarget() {
const target = document.createElement('div');
document.body.appendChild(target);
mountedTargets.push(target);
return target;
}
function dispatchKeyboard(
target: HTMLElement,
options: { key: string; code?: string; repeat?: boolean },
) {
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: options.key,
code: options.code ?? '',
repeat: options.repeat ?? false,
});
target.dispatchEvent(event);
return event;
}
function dispatchPointer(
target: HTMLElement,
type: string,
options: {
button?: number;
clientX: number;
clientY: number;
pointerId?: number;
},
) {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
button: options.button ?? 0,
clientX: options.clientX,
clientY: options.clientY,
});
Object.assign(event, { pointerId: options.pointerId ?? 1 });
target.dispatchEvent(event);
return event;
}
describe('childMotionDebugInput', () => {
test('maps A, D and Space keys to movement and jump actions', () => {
const target = createTarget();
const actions: ChildMotionDebugAction[] = [];
const controller = createChildMotionDebugInputController({
target,
onAction: (action) => actions.push(action),
now: () => 120,
});
const leftEvent = dispatchKeyboard(target, { key: 'a', code: 'KeyA' });
dispatchKeyboard(target, { key: 'D', code: 'KeyD' });
dispatchKeyboard(target, { key: ' ', code: 'Space' });
expect(leftEvent.defaultPrevented).toBe(true);
expect(actions).toEqual([
{
kind: 'move',
direction: 'left',
source: 'keyboard',
occurredAtMs: 120,
},
{
kind: 'move',
direction: 'right',
source: 'keyboard',
occurredAtMs: 120,
},
{
kind: 'jump',
source: 'keyboard',
occurredAtMs: 120,
},
]);
controller.dispose();
});
test('ignores repeated or unrelated keyboard events', () => {
const unrelatedEvent = new KeyboardEvent('keydown', {
key: 'x',
code: 'KeyX',
});
const repeatEvent = new KeyboardEvent('keydown', {
key: 'a',
code: 'KeyA',
repeat: true,
});
expect(resolveKeyboardDebugAction(unrelatedEvent)).toBeNull();
expect(resolveKeyboardDebugAction(repeatEvent)).toBeNull();
});
test('maps left mouse drag to a left hand trajectory', () => {
const target = createTarget();
const actions: ChildMotionDebugAction[] = [];
const controller = createChildMotionDebugInputController({
target,
onAction: (action) => actions.push(action),
now: () => 240,
});
dispatchPointer(target, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 20,
pointerId: 7,
});
dispatchPointer(target, 'pointermove', {
clientX: 18,
clientY: 24,
pointerId: 7,
});
dispatchPointer(target, 'pointerup', {
clientX: 22,
clientY: 28,
pointerId: 7,
});
expect(actions).toEqual([
{
kind: 'hand_trace',
hand: 'left',
phase: 'start',
pointerId: 7,
point: { x: 10, y: 20 },
path: [{ x: 10, y: 20 }],
source: 'pointer',
occurredAtMs: 240,
},
{
kind: 'hand_trace',
hand: 'left',
phase: 'move',
pointerId: 7,
point: { x: 18, y: 24 },
path: [
{ x: 10, y: 20 },
{ x: 18, y: 24 },
],
source: 'pointer',
occurredAtMs: 240,
},
{
kind: 'hand_trace',
hand: 'left',
phase: 'end',
pointerId: 7,
point: { x: 22, y: 28 },
path: [
{ x: 10, y: 20 },
{ x: 18, y: 24 },
{ x: 22, y: 28 },
],
source: 'pointer',
occurredAtMs: 240,
},
]);
controller.dispose();
});
test('maps right mouse drag to a right hand trajectory and prevents context menu', () => {
const target = createTarget();
const actions: ChildMotionDebugAction[] = [];
const controller = createChildMotionDebugInputController({
target,
onAction: (action) => actions.push(action),
now: () => 360,
});
const pointerDown = dispatchPointer(target, 'pointerdown', {
button: 2,
clientX: 30,
clientY: 40,
pointerId: 9,
});
dispatchPointer(target, 'pointermove', {
clientX: 44,
clientY: 48,
pointerId: 9,
});
dispatchPointer(target, 'pointercancel', {
clientX: 48,
clientY: 52,
pointerId: 9,
});
const contextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2,
});
target.dispatchEvent(contextMenuEvent);
expect(pointerDown.defaultPrevented).toBe(true);
expect(contextMenuEvent.defaultPrevented).toBe(true);
expect(actions.map((action) => action.kind)).toEqual([
'hand_trace',
'hand_trace',
'hand_trace',
]);
expect(actions[0]).toMatchObject({
hand: 'right',
phase: 'start',
point: { x: 30, y: 40 },
});
expect(actions[2]).toMatchObject({
hand: 'right',
phase: 'cancel',
point: { x: 48, y: 52 },
});
controller.dispose();
});
test('can be disabled or disposed without emitting debug actions', () => {
const target = createTarget();
const onAction = vi.fn();
const controller = createChildMotionDebugInputController({
target,
onAction,
});
controller.setEnabled(false);
expect(controller.isEnabled()).toBe(false);
dispatchKeyboard(target, { key: 'a', code: 'KeyA' });
dispatchPointer(target, 'pointerdown', {
button: 0,
clientX: 10,
clientY: 20,
});
expect(onAction).not.toHaveBeenCalled();
controller.setEnabled(true);
dispatchKeyboard(target, { key: 'd', code: 'KeyD' });
expect(onAction).toHaveBeenCalledTimes(1);
controller.dispose();
dispatchKeyboard(target, { key: ' ', code: 'Space' });
expect(onAction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,287 @@
export type ChildMotionDebugMoveDirection = 'left' | 'right';
export type ChildMotionDebugHand = 'left' | 'right';
export type ChildMotionDebugHandTracePhase = 'start' | 'move' | 'end' | 'cancel';
export type ChildMotionDebugPoint = {
x: number;
y: number;
};
export type ChildMotionDebugMoveAction = {
kind: 'move';
direction: ChildMotionDebugMoveDirection;
source: 'keyboard';
occurredAtMs: number;
};
export type ChildMotionDebugJumpAction = {
kind: 'jump';
source: 'keyboard';
occurredAtMs: number;
};
export type ChildMotionDebugHandTraceAction = {
kind: 'hand_trace';
hand: ChildMotionDebugHand;
phase: ChildMotionDebugHandTracePhase;
pointerId: number;
point: ChildMotionDebugPoint;
path: ChildMotionDebugPoint[];
source: 'pointer';
occurredAtMs: number;
};
export type ChildMotionDebugAction =
| ChildMotionDebugMoveAction
| ChildMotionDebugJumpAction
| ChildMotionDebugHandTraceAction;
type ChildMotionDebugActionPayload =
| Omit<ChildMotionDebugMoveAction, 'occurredAtMs'>
| Omit<ChildMotionDebugJumpAction, 'occurredAtMs'>
| Omit<ChildMotionDebugHandTraceAction, 'occurredAtMs'>;
export type ChildMotionDebugInputTarget = Pick<
EventTarget,
'addEventListener' | 'removeEventListener'
>;
export type ChildMotionDebugInputOptions = {
target: ChildMotionDebugInputTarget;
onAction: (action: ChildMotionDebugAction) => void;
enabled?: boolean;
now?: () => number;
preventContextMenu?: boolean;
};
export type ChildMotionDebugInputController = {
dispose: () => void;
isEnabled: () => boolean;
setEnabled: (enabled: boolean) => void;
};
type ActiveHandTrace = {
hand: ChildMotionDebugHand;
path: ChildMotionDebugPoint[];
};
const DEFAULT_POINTER_ID = 1;
export function createChildMotionDebugInputController(
options: ChildMotionDebugInputOptions,
): ChildMotionDebugInputController {
const { target, onAction, now = () => Date.now() } = options;
const preventContextMenu = options.preventContextMenu ?? true;
const activeHandTraces = new Map<number, ActiveHandTrace>();
let enabled = options.enabled ?? true;
const emit = (action: ChildMotionDebugActionPayload) => {
onAction({
...action,
occurredAtMs: now(),
});
};
const handleKeyDown = (event: Event) => {
if (!enabled) {
return;
}
const action = resolveKeyboardDebugAction(event);
if (!action) {
return;
}
event.preventDefault();
emit(action);
};
const handlePointerDown = (event: Event) => {
if (!enabled) {
return;
}
const hand = resolvePointerHand(event);
if (!hand) {
return;
}
event.preventDefault();
const pointerId = readPointerId(event);
const point = readPointerPoint(event);
const trace: ActiveHandTrace = {
hand,
path: [point],
};
activeHandTraces.set(pointerId, trace);
emit({
kind: 'hand_trace',
hand,
phase: 'start',
pointerId,
point,
path: trace.path,
source: 'pointer',
});
};
const handlePointerMove = (event: Event) => {
if (!enabled) {
return;
}
const pointerId = readPointerId(event);
const trace = activeHandTraces.get(pointerId);
if (!trace) {
return;
}
event.preventDefault();
const point = readPointerPoint(event);
trace.path = [...trace.path, point];
activeHandTraces.set(pointerId, trace);
emit({
kind: 'hand_trace',
hand: trace.hand,
phase: 'move',
pointerId,
point,
path: trace.path,
source: 'pointer',
});
};
const finishPointerTrace = (
event: Event,
phase: Extract<ChildMotionDebugHandTracePhase, 'end' | 'cancel'>,
) => {
if (!enabled) {
return;
}
const pointerId = readPointerId(event);
const trace = activeHandTraces.get(pointerId);
if (!trace) {
return;
}
event.preventDefault();
const point = readPointerPoint(event);
const path = [...trace.path, point];
activeHandTraces.delete(pointerId);
emit({
kind: 'hand_trace',
hand: trace.hand,
phase,
pointerId,
point,
path,
source: 'pointer',
});
};
const handlePointerUp = (event: Event) => finishPointerTrace(event, 'end');
const handlePointerCancel = (event: Event) =>
finishPointerTrace(event, 'cancel');
const handleContextMenu = (event: Event) => {
if (enabled && preventContextMenu) {
event.preventDefault();
}
};
target.addEventListener('keydown', handleKeyDown);
target.addEventListener('pointerdown', handlePointerDown);
target.addEventListener('pointermove', handlePointerMove);
target.addEventListener('pointerup', handlePointerUp);
target.addEventListener('pointercancel', handlePointerCancel);
target.addEventListener('contextmenu', handleContextMenu);
return {
dispose: () => {
activeHandTraces.clear();
target.removeEventListener('keydown', handleKeyDown);
target.removeEventListener('pointerdown', handlePointerDown);
target.removeEventListener('pointermove', handlePointerMove);
target.removeEventListener('pointerup', handlePointerUp);
target.removeEventListener('pointercancel', handlePointerCancel);
target.removeEventListener('contextmenu', handleContextMenu);
},
isEnabled: () => enabled,
setEnabled: (nextEnabled: boolean) => {
enabled = nextEnabled;
if (!enabled) {
activeHandTraces.clear();
}
},
};
}
export function resolveKeyboardDebugAction(
event: Event,
):
| Omit<ChildMotionDebugMoveAction, 'occurredAtMs'>
| Omit<ChildMotionDebugJumpAction, 'occurredAtMs'>
| null {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.repeat) {
return null;
}
const normalizedKey = keyboardEvent.key?.toLocaleLowerCase('en-US') ?? '';
const normalizedCode = keyboardEvent.code ?? '';
if (normalizedKey === 'a' || normalizedCode === 'KeyA') {
return {
kind: 'move',
direction: 'left',
source: 'keyboard',
};
}
if (normalizedKey === 'd' || normalizedCode === 'KeyD') {
return {
kind: 'move',
direction: 'right',
source: 'keyboard',
};
}
if (
keyboardEvent.key === ' ' ||
keyboardEvent.key === 'Spacebar' ||
normalizedCode === 'Space'
) {
return {
kind: 'jump',
source: 'keyboard',
};
}
return null;
}
function resolvePointerHand(event: Event): ChildMotionDebugHand | null {
const button = (event as MouseEvent).button;
if (button === 0) {
return 'left';
}
if (button === 2) {
return 'right';
}
return null;
}
function readPointerId(event: Event) {
const pointerId = (event as PointerEvent).pointerId;
return typeof pointerId === 'number' ? pointerId : DEFAULT_POINTER_ID;
}
function readPointerPoint(event: Event): ChildMotionDebugPoint {
const mouseEvent = event as MouseEvent;
return {
x: mouseEvent.clientX,
y: mouseEvent.clientY,
};
}

View File

@@ -0,0 +1 @@
export * from './childMotionDebugInput';