文件
tennis-training-hub/client/src/lib/liveCamera.test.ts
2026-03-15 22:32:09 +08:00

130 行
4.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
ACTION_WINDOW_FRAMES,
AVATAR_PRESETS,
createStableActionState,
getAvatarAnchors,
getAvatarPreset,
resolveAvatarKeyFromPrompt,
stabilizeActionStream,
type FrameActionSample,
} from "./liveCamera";
function feedSamples(samples: Array<Omit<FrameActionSample, "timestamp">>, intervalMs = 33) {
const history: FrameActionSample[] = [];
const state = createStableActionState();
let lastResult = null as ReturnType<typeof stabilizeActionStream> | null;
samples.forEach((sample, index) => {
lastResult = stabilizeActionStream(
{
...sample,
timestamp: index * intervalMs,
},
history,
state,
);
});
return { history, state, lastResult };
}
describe("live camera action stabilizer", () => {
it("locks a dominant action after a full temporal window", () => {
const samples = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.84,
}));
const { lastResult } = feedSamples(samples);
expect(lastResult?.stableAction).toBe("forehand");
expect(lastResult?.windowAction).toBe("forehand");
expect(lastResult?.pending).toBe(false);
expect(lastResult?.windowShare).toBeGreaterThan(0.9);
});
it("ignores brief action spikes and keeps the stable action", () => {
const stableFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.82,
}));
const noisyFrames = Array.from({ length: 5 }, () => ({
action: "backhand" as const,
confidence: 0.88,
}));
const { lastResult } = feedSamples([...stableFrames, ...noisyFrames]);
expect(lastResult?.stableAction).toBe("forehand");
expect(lastResult?.pending).toBe(false);
});
it("switches only after the next action persists long enough", () => {
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.8,
}));
const backhandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "backhand" as const,
confidence: 0.85,
}));
const { lastResult, state } = feedSamples([...forehandFrames, ...backhandFrames]);
expect(lastResult?.stableAction).toBe("backhand");
expect(state.switchCount).toBeGreaterThanOrEqual(2);
});
it("requires a longer delay before falling back to unknown", () => {
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
action: "forehand" as const,
confidence: 0.83,
}));
const unknownFrames = Array.from({ length: 10 }, () => ({
action: "unknown" as const,
confidence: 0.4,
}));
const { lastResult } = feedSamples([...forehandFrames, ...unknownFrames]);
expect(lastResult?.stableAction).toBe("forehand");
});
});
describe("live camera avatar helpers", () => {
it("maps prompt keywords into avatar presets", () => {
expect(resolveAvatarKeyFromPrompt("切换成猩猩形象", "gorilla")).toBe("gorilla");
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
expect(resolveAvatarKeyFromPrompt("BeachKing 3D 替身", "gorilla")).toBe("beachKing");
expect(resolveAvatarKeyFromPrompt("Juanita avatar", "gorilla")).toBe("juanita3d");
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
});
it("exposes full-body 3d avatar examples with CC0 metadata", () => {
const presets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
expect(presets).toHaveLength(4);
expect(presets.every((preset) => preset.license === "CC0")).toBe(true);
expect(getAvatarPreset("sportTv")?.modelUrl).toContain("arweave.net");
});
it("builds avatar anchors from pose landmarks", () => {
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
landmarks[11] = { x: 0.4, y: 0.3, visibility: 0.99 };
landmarks[12] = { x: 0.6, y: 0.3, visibility: 0.99 };
landmarks[15] = { x: 0.28, y: 0.44, visibility: 0.99 };
landmarks[16] = { x: 0.72, y: 0.44, visibility: 0.99 };
landmarks[23] = { x: 0.44, y: 0.58, visibility: 0.99 };
landmarks[24] = { x: 0.56, y: 0.58, visibility: 0.99 };
landmarks[27] = { x: 0.43, y: 0.92, visibility: 0.99 };
landmarks[28] = { x: 0.57, y: 0.92, visibility: 0.99 };
const anchors = getAvatarAnchors(landmarks, 1280, 720);
expect(anchors).not.toBeNull();
expect(anchors?.headRadius).toBeGreaterThan(30);
expect(anchors?.bodyHeight).toBeGreaterThan(120);
expect(anchors?.rightHandX).toBeGreaterThan(anchors?.leftHandX || 0);
});
});