* feat(canvas): 3D 导演台 - 补齐分层交互与回归保护 * fix(canvas): 3D 导演台 - 过滤非法轨迹关键帧 * chore(canvas): 3D 导演台 - 对齐前端格式规范 * fix(canvas): 3D 导演台 - 保证故障重试入口可点击 * test(canvas): 3D 导演台 - 稳定真实点击验证
211 lines
11 KiB
TypeScript
211 lines
11 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import {
|
||
DIRECTOR_KEYFRAME_EPSILON,
|
||
createDirectorActor,
|
||
createDirectorCamera,
|
||
createDirectorScene,
|
||
interpolateDirectorTransform,
|
||
removeDirectorBoneKeyframe,
|
||
removeDirectorKeyframe,
|
||
removeDirectorSceneKeyframe,
|
||
resolveDirectorKeyframeProgress,
|
||
setDirectorSceneKeyframeEasing,
|
||
upsertDirectorBoneKeyframe,
|
||
upsertDirectorKeyframe,
|
||
} from "../src/lib/canvas/director/director-scene";
|
||
import type { DirectorKeyframe, DirectorQuat, DirectorScene, DirectorTransform } from "../src/types/director";
|
||
|
||
const transformAt = (x: number): DirectorTransform => ({ position: [x, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] });
|
||
|
||
function seededKeyframes(): DirectorKeyframe[] {
|
||
let keys: DirectorKeyframe[] = [];
|
||
keys = upsertDirectorKeyframe(keys, 0, transformAt(0));
|
||
keys = upsertDirectorKeyframe(keys, 1, transformAt(10));
|
||
keys = upsertDirectorKeyframe(keys, 2, transformAt(20));
|
||
return keys;
|
||
}
|
||
|
||
describe("删除 transform 关键帧", () => {
|
||
test("按 id 删除只移除目标帧,其余保持有序", () => {
|
||
const keys = seededKeyframes();
|
||
const next = removeDirectorKeyframe(keys, keys[1].id);
|
||
|
||
expect(next).toHaveLength(2);
|
||
expect(next.map((k) => k.time)).toEqual([0, 2]);
|
||
// 纯函数:不得改写入参。
|
||
expect(keys).toHaveLength(3);
|
||
});
|
||
|
||
test("id 不存在时返回原引用,避免无意义重渲染", () => {
|
||
const keys = seededKeyframes();
|
||
expect(removeDirectorKeyframe(keys, "missing")).toBe(keys);
|
||
});
|
||
|
||
test("删空后得到空数组,插值回落到 base", () => {
|
||
let keys = upsertDirectorKeyframe([], 1, transformAt(5));
|
||
keys = removeDirectorKeyframe(keys, keys[0].id);
|
||
expect(keys).toEqual([]);
|
||
expect(interpolateDirectorTransform(transformAt(99), keys, 1).position[0]).toBe(99);
|
||
});
|
||
|
||
test("upsert 使用同一容差,记录下的帧一定能按 id 删除", () => {
|
||
// 非整帧时间点最容易出现「记录能覆盖但删不掉」,这里锁住判据一致。
|
||
let keys = upsertDirectorKeyframe([], 0.75, transformAt(3));
|
||
keys = upsertDirectorKeyframe(keys, 0.75 + DIRECTOR_KEYFRAME_EPSILON / 2, transformAt(4));
|
||
expect(keys).toHaveLength(1);
|
||
expect(removeDirectorKeyframe(keys, keys[0].id)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("关键帧缓动", () => {
|
||
test("linear / step / smooth 产生不同且确定的区间进度", () => {
|
||
expect(resolveDirectorKeyframeProgress(0.25, "linear")).toBe(0.25);
|
||
expect(resolveDirectorKeyframeProgress(0.25, "step")).toBe(0);
|
||
expect(resolveDirectorKeyframeProgress(0.25, "smooth")).toBeCloseTo(0.15625, 8);
|
||
expect(resolveDirectorKeyframeProgress(-1, "linear")).toBe(0);
|
||
expect(resolveDirectorKeyframeProgress(2, "linear")).toBe(1);
|
||
});
|
||
|
||
test("插值读取前一枚关键帧的 easing,旧数据默认线性", () => {
|
||
const base = seededKeyframes().slice(0, 2);
|
||
expect(interpolateDirectorTransform(transformAt(0), base, 0.25).position[0]).toBeCloseTo(2.5, 8);
|
||
expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 0.25).position[0]).toBe(0);
|
||
expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "smooth" }, base[1]], 0.25).position[0]).toBeCloseTo(1.5625, 8);
|
||
expect(interpolateDirectorTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 1).position[0]).toBe(10);
|
||
});
|
||
});
|
||
|
||
describe("删除骨骼关键帧", () => {
|
||
const rotation: DirectorQuat = [0, 0, 0, 1];
|
||
|
||
test("删除后轨道保留其余帧", () => {
|
||
let tracks = upsertDirectorBoneKeyframe([], "hips", 0, rotation);
|
||
tracks = upsertDirectorBoneKeyframe(tracks, "hips", 1, rotation);
|
||
const target = tracks[0].keyframes[0].id;
|
||
|
||
const next = removeDirectorBoneKeyframe(tracks, "hips", target);
|
||
expect(next).toHaveLength(1);
|
||
expect(next[0].keyframes).toHaveLength(1);
|
||
expect(next[0].keyframes[0].time).toBe(1);
|
||
});
|
||
|
||
test("轨道被删空后整条移除,不留空子轨道", () => {
|
||
const tracks = upsertDirectorBoneKeyframe([], "head", 0, rotation);
|
||
const next = removeDirectorBoneKeyframe(tracks, "head", tracks[0].keyframes[0].id);
|
||
expect(next).toEqual([]);
|
||
});
|
||
|
||
test("骨骼或 id 不存在时返回原引用", () => {
|
||
const tracks = upsertDirectorBoneKeyframe([], "head", 0, rotation);
|
||
expect(removeDirectorBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id)).toBe(tracks);
|
||
expect(removeDirectorBoneKeyframe(tracks, "head", "missing")).toBe(tracks);
|
||
});
|
||
|
||
test("只影响目标骨骼,其他轨道不动", () => {
|
||
let tracks = upsertDirectorBoneKeyframe([], "hips", 0, rotation);
|
||
tracks = upsertDirectorBoneKeyframe(tracks, "head", 0, rotation);
|
||
const next = removeDirectorBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id);
|
||
expect(next.map((t) => t.bone)).toEqual(["head"]);
|
||
});
|
||
});
|
||
|
||
describe("removeDirectorSceneKeyframe:时间轴删除的唯一分派入口", () => {
|
||
const rotation: DirectorQuat = [0, 0, 0, 1];
|
||
|
||
/** 一个带对象 transform 帧、骨骼帧和摄影机帧的完整场景。 */
|
||
function seededScene() {
|
||
const base = createDirectorScene("删除测试");
|
||
const actor = {
|
||
...createDirectorActor("演员 A"),
|
||
keyframes: upsertDirectorKeyframe(upsertDirectorKeyframe([], 0, transformAt(0)), 1, transformAt(10)),
|
||
boneTracks: upsertDirectorBoneKeyframe(upsertDirectorBoneKeyframe([], "hips", 0, rotation), "head", 0.5, rotation),
|
||
};
|
||
const camera = { ...createDirectorCamera("摄影机 A"), keyframes: upsertDirectorKeyframe([], 2, transformAt(5)) };
|
||
const scene: DirectorScene = { ...base, objects: [actor], cameras: [camera] };
|
||
return { scene, actor, camera };
|
||
}
|
||
|
||
test("删除对象 transform 帧,只动目标对象", () => {
|
||
const { scene, actor } = seededScene();
|
||
const next = removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id });
|
||
|
||
expect(next).not.toBe(scene);
|
||
expect(next.objects[0].keyframes.map((key) => key.time)).toEqual([1]);
|
||
// 骨骼轨道和摄影机不受影响,保持原引用。
|
||
expect(next.objects[0].boneTracks).toBe(actor.boneTracks);
|
||
expect(next.cameras).toBe(scene.cameras);
|
||
});
|
||
|
||
test("删除骨骼帧,删空的子轨道整条移除", () => {
|
||
const { scene, actor } = seededScene();
|
||
const hips = actor.boneTracks.find((track) => track.bone === "hips");
|
||
const next = removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: hips!.keyframes[0].id });
|
||
|
||
expect(next.objects[0].boneTracks?.map((track) => track.bone)).toEqual(["head"]);
|
||
expect(next.objects[0].keyframes).toBe(actor.keyframes);
|
||
});
|
||
|
||
test("删除摄影机帧,只动目标摄影机", () => {
|
||
const { scene, camera } = seededScene();
|
||
const next = removeDirectorSceneKeyframe(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id });
|
||
|
||
expect(next.cameras[0].keyframes).toEqual([]);
|
||
expect(next.objects).toBe(scene.objects);
|
||
});
|
||
|
||
test("未命中一律返回同一 scene 引用:调用方据此跳过历史与保存", () => {
|
||
const { scene, actor, camera } = seededScene();
|
||
// 对象存在但帧 id 不存在。
|
||
expect(removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: "missing" })).toBe(scene);
|
||
// 对象本身不存在。
|
||
expect(removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: "missing", keyframeId: actor.keyframes[0].id })).toBe(scene);
|
||
// 骨骼轨道不存在。
|
||
expect(removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "leftHand", keyframeId: "missing" })).toBe(scene);
|
||
// 摄影机不存在。
|
||
expect(removeDirectorSceneKeyframe(scene, { track: "camera", cameraId: "missing", keyframeId: camera.keyframes[0].id })).toBe(scene);
|
||
});
|
||
|
||
test("对象没有 boneTracks 时删除骨骼帧不虚构空数组", () => {
|
||
const base = createDirectorScene("无骨骼");
|
||
const object = { ...createDirectorActor("演员 B"), boneTracks: undefined };
|
||
const scene: DirectorScene = { ...base, objects: [object] };
|
||
expect(removeDirectorSceneKeyframe(scene, { track: "object-bone", objectId: object.id, bone: "hips", keyframeId: "any" })).toBe(scene);
|
||
});
|
||
|
||
test("不改写入参:原 scene 与原数组保持完整", () => {
|
||
const { scene, actor } = seededScene();
|
||
removeDirectorSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id });
|
||
expect(scene.objects[0].keyframes).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe("setDirectorSceneKeyframeEasing:按轨道更新且保持纯函数", () => {
|
||
test("对象、骨骼和摄影机三类目标都能独立更新", () => {
|
||
const base = createDirectorScene("缓动测试");
|
||
const actor = {
|
||
...createDirectorActor("演员"),
|
||
keyframes: upsertDirectorKeyframe([], 0, transformAt(0)),
|
||
boneTracks: upsertDirectorBoneKeyframe([], "hips", 0, [0, 0, 0, 1]),
|
||
};
|
||
const camera = { ...createDirectorCamera("摄影机"), keyframes: upsertDirectorKeyframe([], 0, transformAt(1)) };
|
||
const scene: DirectorScene = { ...base, objects: [actor], cameras: [camera] };
|
||
|
||
const objectNext = setDirectorSceneKeyframeEasing(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id }, "smooth");
|
||
expect(objectNext.objects[0].keyframes[0].easing).toBe("smooth");
|
||
expect(objectNext.cameras).toBe(scene.cameras);
|
||
|
||
const boneNext = setDirectorSceneKeyframeEasing(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: actor.boneTracks[0].keyframes[0].id }, "step");
|
||
expect(boneNext.objects[0].boneTracks?.[0].keyframes[0].easing).toBe("step");
|
||
|
||
const cameraNext = setDirectorSceneKeyframeEasing(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id }, "linear");
|
||
expect(cameraNext.cameras[0].keyframes[0].easing).toBe("linear");
|
||
expect(scene.objects[0].keyframes[0].easing).toBeUndefined();
|
||
});
|
||
|
||
test("目标不存在时返回原 scene 引用", () => {
|
||
const scene = createDirectorScene("missing");
|
||
expect(setDirectorSceneKeyframeEasing(scene, { track: "camera", cameraId: "missing", keyframeId: "missing" }, "smooth")).toBe(scene);
|
||
});
|
||
});
|