Files
planet/frontend/public/earth/js/motion-control.test.js
linkong 899e3bce43
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.71.0
2026-06-11 16:47:24 +08:00

863 lines
30 KiB
JavaScript

import { describe, expect, test } from "bun:test";
import {
createMotionControlAdapter,
normalizeMotionProvider,
} from "./motion-control.js";
import {
createBrowserCameraProvider,
recognizeGesture,
} from "./motion-browser-provider.js";
class TestEventTarget {
constructor() {
this.events = [];
this.listeners = new Map();
}
dispatchEvent(event) {
this.events.push(event);
(this.listeners.get(event.type) || []).forEach((listener) => listener(event));
return true;
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
this.listeners.set(type, listeners.filter((item) => item !== listener));
}
}
function installWindow(search = "") {
const target = new TestEventTarget();
globalThis.CustomEvent = class CustomEvent {
constructor(type, options = {}) {
this.type = type;
this.detail = options.detail;
}
};
globalThis.window = {
location: {
search,
hostname: "localhost",
},
isSecureContext: true,
localStorage: {
getItem() {
return null;
},
},
dispatchEvent: target.dispatchEvent.bind(target),
addEventListener: target.addEventListener.bind(target),
removeEventListener: target.removeEventListener.bind(target),
};
return target;
}
function installDocument() {
const tracks = [];
globalThis.document = {
body: {
appendChild() {},
},
createElement(tagName) {
expect(tagName).toBe("video");
return {
muted: false,
playsInline: false,
autoplay: false,
readyState: 4,
style: {},
srcObject: null,
videoWidth: 640,
videoHeight: 360,
addEventListener() {},
play: () => Promise.resolve(),
pause() {},
remove() {},
};
},
};
return tracks;
}
describe("motion-control provider manager", () => {
test("normalizes browser and agent provider aliases", () => {
expect(normalizeMotionProvider("browser")).toBe("browser_camera");
expect(normalizeMotionProvider("agent")).toBe("motion_agent");
expect(normalizeMotionProvider("motion_agent")).toBe("motion_agent");
expect(normalizeMotionProvider("unknown")).toBe("browser_camera");
});
test("mock skeleton message dispatches debug frame event", () => {
const events = installWindow();
const adapter = createMotionControlAdapter({
enabled: true,
providerFactories: {
browser_camera: ({ onMessage, onState }) => ({
start() {
onState({ connected: true });
onMessage({
type: "skeleton",
timestamp_ms: 1000,
source: "test",
joints: [{ id: "left_wrist", x: 0.2, y: 0.3, confidence: 1 }],
bones: [],
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
const debugEvent = events.events.find((event) => event.type === "earth:motion-debug-frame");
expect(debugEvent?.detail.joints[0]).toEqual({
id: "left_wrist",
x: 0.2,
y: 0.3,
confidence: 1,
});
});
test("mock gesture message maps to Earth control callback", () => {
installWindow();
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: ({ onMessage }) => ({
start() {
onMessage({
type: "gesture",
gesture: "rotate_left",
confidence: 0.91,
intensity: 0.5,
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
expect(rotations[0][0]).toBe("horizontal");
expect(rotations[0][1]).toBe("left");
expect(rotations[0][2]).toBe(0.5);
});
test("recognition pause suppresses gestures and matched skeleton state", () => {
const events = installWindow();
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
window.dispatchEvent(new CustomEvent("earth:motion-recognition-pause", {
detail: { paused: true },
}));
adapter.handleMessage({
type: "gesture",
gesture: "rotate_left",
confidence: 0.91,
intensity: 0.5,
});
adapter.handleMessage({
type: "skeleton",
matched_gesture: "rotate_left",
confidence: 0.91,
joints: [],
bones: [],
});
const debugEvent = events.events.findLast?.((event) => event.type === "earth:motion-debug-frame") ||
events.events.filter((event) => event.type === "earth:motion-debug-frame").at(-1);
expect(rotations).toHaveLength(0);
expect(debugEvent?.detail.matchedGesture).toBeNull();
expect(debugEvent?.detail.confidence).toBe(0);
});
test("disabled gestures are ignored and can be re-enabled at runtime", () => {
installWindow();
const rotations = [];
const sentCommands = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
enabledGestures: ["zoom_in"],
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
sendCommand(command, payload) {
sentCommands.push({ command, payload });
return { ok: true };
},
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
expect(rotations).toHaveLength(0);
adapter.setEnabledGestures(["rotate_left"]);
adapter.handleMessage({ type: "gesture", gesture: "rotate_left", confidence: 0.91 });
expect(rotations).toHaveLength(1);
expect(sentCommands.at(-1)).toEqual({
command: "set_enabled_gestures",
payload: { gestures: ["rotate_left"] },
});
});
test("mock vertical gesture and focus gesture use dedicated callbacks", () => {
installWindow();
const rotations = [];
const focuses = [];
const layers = [];
const adapter = createMotionControlAdapter({
enabled: true,
cooldownMs: 0,
onRotate: (...args) => rotations.push(args),
onFocus: (...args) => focuses.push(args),
onLayer: (...args) => layers.push(args),
providerFactories: {
browser_camera: ({ onMessage }) => ({
start() {
onMessage({
type: "gesture",
gesture: "rotate_up",
confidence: 0.91,
intensity: 0.6,
});
onMessage({
type: "gesture",
gesture: "focus_next",
confidence: 0.91,
intensity: 0.8,
});
onMessage({
type: "gesture",
gesture: "layer_next",
confidence: 0.91,
intensity: 0.8,
});
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
expect(rotations[0][0]).toBe("vertical");
expect(rotations[0][1]).toBe("up");
expect(rotations[0][2]).toBe(0.6);
expect(focuses[0][0]).toBe("next");
expect(layers[0][0]).toBe("next");
});
test("continuous rotate can retrigger after the short cooldown", () => {
installWindow();
let now = 1000;
const rotations = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onRotate: (...args) => rotations.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
now += 60;
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
now += 70;
adapter.handleMessage({ type: "gesture", gesture: "rotate_right", confidence: 0.91 });
expect(rotations).toHaveLength(2);
});
test("focus gestures share one 900ms cooldown group", () => {
installWindow();
let now = 1000;
const focuses = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onFocus: (...args) => focuses.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "focus_next", confidence: 0.91 });
now += 100;
adapter.handleMessage({ type: "gesture", gesture: "focus_prev", confidence: 0.91 });
now += 900;
adapter.handleMessage({ type: "gesture", gesture: "focus_prev", confidence: 0.91 });
expect(focuses.map((args) => args[0])).toEqual(["next", "prev"]);
});
test("layer gestures share one 1400ms cooldown group", () => {
installWindow();
let now = 1000;
const layers = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onLayer: (...args) => layers.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "layer_next", confidence: 0.91 });
now += 1200;
adapter.handleMessage({ type: "gesture", gesture: "layer_next", confidence: 0.91 });
now += 200;
adapter.handleMessage({ type: "gesture", gesture: "layer_prev", confidence: 0.91 });
expect(layers.map((args) => args[0])).toEqual(["next", "prev"]);
});
test("confirm uses a 1200ms cooldown", () => {
installWindow();
let now = 1000;
const confirms = [];
const adapter = createMotionControlAdapter({
enabled: true,
nowFn: () => now,
onConfirm: (...args) => confirms.push(args),
providerFactories: {
browser_camera: () => ({
start() {
return true;
},
stop() {},
isConnected: () => true,
}),
},
});
adapter.start();
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
now += 1000;
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
now += 200;
adapter.handleMessage({ type: "gesture", gesture: "confirm", confidence: 0.91 });
expect(confirms).toHaveLength(2);
});
});
function createPoseJoints(overrides = {}) {
const base = {
left_ear: { id: "left_ear", x: 0.45, y: 0.2, confidence: 1 },
right_ear: { id: "right_ear", x: 0.55, y: 0.2, confidence: 1 },
left_shoulder: { id: "left_shoulder", x: 0.42, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.58, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.4, y: 0.62, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.6, y: 0.62, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.42, y: 0.65, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.65, confidence: 1 },
};
return Object.values({
...base,
...overrides,
});
}
describe("browser camera gesture semantics", () => {
test("right-arm left-facing pattern maps to rotate_right", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.4, y: 0.53, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_right");
});
test("right-arm upward terminal pattern maps to rotate_up", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.68, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.71, y: 0.39, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_up");
});
test("right-arm terminal can float about 30 degrees while matching horizontal wave", () => {
const current = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.39, y: 0.56, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("rotate_right");
});
test("left-hand vertical movement switches motion layer", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.48, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.38, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("layer_prev");
});
test("centered close hands no longer trigger confirm", () => {
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.48, y: 0.6, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.52, y: 0.6, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("head tilt emits focus navigation gesture", () => {
const current = createPoseJoints({
left_ear: { id: "left_ear", x: 0.45, y: 0.24, confidence: 1 },
right_ear: { id: "right_ear", x: 0.55, y: 0.18, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("focus_prev");
});
test("holding right hand high does not continuously rotate", () => {
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.34, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("holding the same pattern only emits once until neutral", () => {
const state = {};
const pattern = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.5, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.4, y: 0.5, confidence: 1 },
});
const neutral = createPoseJoints();
expect(recognizeGesture(pattern, neutral, { state })?.gesture).toBe("rotate_right");
expect(recognizeGesture(pattern, pattern, { state })).toBeNull();
expect(recognizeGesture(neutral, pattern, { state })).toBeNull();
expect(recognizeGesture(pattern, neutral, { state })?.gesture).toBe("rotate_right");
});
test("hands resting below the shoulders do not rotate down", () => {
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.82, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("moving left hand near the chest does not trigger zoom out", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.62, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.55, y: 0.62, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.49, y: 0.62, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.55, y: 0.62, confidence: 1 },
});
expect(recognizeGesture(current, previous)).toBeNull();
});
test("two hands opening from center trigger zoom in", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.34, y: 0.4, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.4, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_in");
});
test("two hands opening with tilted wrists still trigger zoom in", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.37, y: 0.51, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.31, y: 0.46, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.63, y: 0.51, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.69, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_in");
});
test("open arms within a 30 degree vertical fan trigger zoom in", () => {
const upwardFan = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.35, y: 0.48, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.29, y: 0.43, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.65, y: 0.52, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.57, confidence: 1 },
});
const downwardFan = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.35, y: 0.52, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.29, y: 0.57, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.65, y: 0.48, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.43, confidence: 1 },
});
expect(recognizeGesture(upwardFan, upwardFan)?.gesture).toBe("zoom_in");
expect(recognizeGesture(downwardFan, downwardFan)?.gesture).toBe("zoom_in");
});
test("near zoom-in pose suppresses right-arm rotate while the second hand catches up", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.4, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.35, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.7, y: 0.39, confidence: 1 },
});
expect(recognizeGesture(current, current)).toBeNull();
});
test("two hands closing toward center trigger zoom out", () => {
const current = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.34, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.46, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.54, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(current, current)?.gesture).toBe("zoom_out");
});
// Two-arm spread starts asymmetrically: the right wrist crosses the rotate
// trigger threshold a frame or two before the left wrist catches up. The
// mirror-safe span-based pose detector recognises this frame as a spread
// and emits zoom_in instead of letting the stale single-arm rotate fire.
test("mid-spread emits zoom_in (not a stray right-arm rotate) while the left arm is still extending", () => {
const midSpread = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.66, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
});
const result = recognizeGesture(midSpread, midSpread);
expect(result?.gesture).toBe("zoom_in");
});
// Earliest-spread case: the left wrist has just started to lift toward
// shoulder height while still sitting at the body line. The right wrist has
// already crossed the rotate threshold. The mirror-safe pose detector
// recognises the wide span and fires zoom_in; the at-rest gate ensures
// rotate cannot fire in this configuration either.
test("early-spread frame fires zoom_in instead of a stray right-arm rotate", () => {
const earlySpread = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.72, y: 0.5, confidence: 1 },
left_elbow: { id: "left_elbow", x: 0.41, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.42, y: 0.58, confidence: 1 },
});
const result = recognizeGesture(earlySpread, earlySpread);
expect(result?.gesture).toBe("zoom_in");
});
// The suppressor must not over-fire: a deliberate single right-arm wave
// with the left arm at rest still needs to map to a rotate gesture.
test("right-arm wave with the left arm at rest still triggers rotate_left", () => {
const wave = createPoseJoints({
right_elbow: { id: "right_elbow", x: 0.65, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.74, y: 0.5, confidence: 1 },
});
expect(recognizeGesture(wave, wave)?.gesture).toBe("rotate_left");
});
// Trend-based zoom: anti-symmetric wrist motion is the strongest signal of
// intent. Pose matching alone is brittle because MediaPipe keypoints
// jitter; tracking direction-of-motion catches the gesture as soon as it
// starts.
test("wrists drifting apart trigger zoom_in via trend detection", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.62, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
});
test("wrists drifting together trigger zoom_out via trend detection", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.30, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.34, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
});
// Single-arm motion (right wrist moving while left wrist is stationary)
// must NOT trip the trend zoom — only anti-symmetric motion of both
// wrists qualifies.
test("single-arm motion does not trigger trend-based zoom", () => {
const previous = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.58, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
right_wrist: { id: "right_wrist", x: 0.70, y: 0.55, confidence: 1 },
});
const result = recognizeGesture(current, previous);
expect(result?.gesture).not.toBe("zoom_in");
expect(result?.gesture).not.toBe("zoom_out");
});
// If the two wrists are at very different heights (one resting, one
// raised), trend detection must NOT fire — that's a one-arm gesture.
test("trend zoom requires the wrists to be at roughly the same height", () => {
const previous = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.42, y: 0.80, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.58, y: 0.30, confidence: 1 },
});
const current = createPoseJoints({
left_wrist: { id: "left_wrist", x: 0.38, y: 0.80, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.62, y: 0.30, confidence: 1 },
});
const result = recognizeGesture(current, previous);
expect(result?.gesture).not.toBe("zoom_in");
expect(result?.gesture).not.toBe("zoom_out");
});
// Zoom is a sustained gesture: while the user holds a spread T-pose the
// recognizer must keep emitting zoom_in every frame so the globe keeps
// zooming. This is unlike rotate, which should emit once per wave.
test("holding a spread pose emits zoom_in on every frame", () => {
const state = {};
const spread = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.36, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.34, y: 0.4, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.64, y: 0.5, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.66, y: 0.4, confidence: 1 },
});
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
expect(recognizeGesture(spread, spread, { state })?.gesture).toBe("zoom_in");
});
// Mirror-safe trend: on a non-mirrored camera feed the subject's anatomical
// left arm appears on the image right (left_shoulder.x > right_shoulder.x).
// Spreading the arms must still fire zoom_in (not zoom_out) because the
// span between the wrists grows regardless of camera orientation.
test("non-mirrored camera: spreading wrists still triggers zoom_in", () => {
const previous = createPoseJoints({
// Swapped layout: anatomical left on image right
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.58, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.42, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
// Subject's left arm extending right in the image; subject's right
// arm extending left in the image. Span widens either way.
left_wrist: { id: "left_wrist", x: 0.62, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.38, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_in");
});
// Mirror-safe trend: on the same non-mirrored layout, hands coming together
// must still fire zoom_out.
test("non-mirrored camera: closing wrists still triggers zoom_out", () => {
const previous = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.70, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.30, y: 0.55, confidence: 1 },
});
const current = createPoseJoints({
left_shoulder: { id: "left_shoulder", x: 0.58, y: 0.5, confidence: 1 },
right_shoulder: { id: "right_shoulder", x: 0.42, y: 0.5, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.34, y: 0.55, confidence: 1 },
});
expect(recognizeGesture(current, previous)?.gesture).toBe("zoom_out");
});
// Zoom_out must also be sustained — closing the hands and holding
// continues to zoom out.
test("holding a closed pose emits zoom_out on every frame", () => {
const state = {};
const closed = createPoseJoints({
left_elbow: { id: "left_elbow", x: 0.34, y: 0.55, confidence: 1 },
left_wrist: { id: "left_wrist", x: 0.46, y: 0.58, confidence: 1 },
right_elbow: { id: "right_elbow", x: 0.66, y: 0.55, confidence: 1 },
right_wrist: { id: "right_wrist", x: 0.54, y: 0.58, confidence: 1 },
});
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
expect(recognizeGesture(closed, closed, { state })?.gesture).toBe("zoom_out");
});
});
describe("browser camera provider", () => {
test("starts with mocked getUserMedia and emits active state", async () => {
installWindow();
installDocument();
const stopped = [];
const states = [];
const videoSources = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () =>
Promise.resolve({
getTracks: () => [{ stop: () => stopped.push("camera") }],
}),
},
recognizerFactory: () =>
Promise.resolve({
recognize: () => [],
close() {},
}),
requestAnimationFrameFn: () => 0,
onState: (state) => states.push(state),
onVideoSource: (source) => videoSources.push(source),
});
await provider.start();
provider.stop();
expect(states.some((state) => state.connected === true)).toBe(true);
expect(stopped).toEqual(["camera"]);
expect(videoSources.some((source) => source.active === true)).toBe(true);
expect(videoSources.at(-1)).toEqual({
provider: "browser_camera",
source: null,
active: false,
});
});
test("reports permission errors without falling back to dry-run", async () => {
installWindow();
installDocument();
const statuses = [];
const closed = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () =>
Promise.reject(Object.assign(new Error("denied"), { name: "NotAllowedError" })),
},
recognizerFactory: () =>
Promise.resolve({
recognize: () => [],
close: () => closed.push("recognizer"),
}),
onStatus: (message, type) => statuses.push({ message, type }),
});
const started = await provider.start();
expect(started).toBe(false);
expect(statuses[0]).toEqual({
message: "浏览器摄像头权限被拒绝",
type: "error",
});
expect(closed).toEqual(["recognizer"]);
});
test("reports model load errors separately from camera permission", async () => {
installWindow();
installDocument();
const statuses = [];
const requested = [];
const provider = createBrowserCameraProvider({
mediaDevices: {
getUserMedia: () => {
requested.push("camera");
return Promise.resolve({
getTracks: () => [],
});
},
},
recognizerFactory: () => Promise.reject(new Error("model unavailable")),
onStatus: (message, type) => statuses.push({ message, type }),
});
const started = await provider.start();
expect(started).toBe(false);
expect(requested).toEqual([]);
expect(statuses[0]).toEqual({
message: "浏览器动捕模型加载失败: model unavailable",
type: "error",
});
});
});