Files
ttstd-infinite-canvas/web/test/director-recovery.test.ts

349 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import {
previsCaptureInitial,
previsCaptureUsable,
previsLoadIdentity,
previsLoadInitial,
installPrevisContextListeners,
reducePrevisCapture,
reducePrevisLoad,
removePrevisFailedLoad,
releasePrevisCapture,
resolvePrevisDisplay,
restorePrevisCapture,
upsertPrevisFailedLoad,
type PrevisCanvasTarget,
type PrevisFailedLoads,
} from "../src/lib/canvas/previs/previs-recovery";
function fakeCanvas() {
const listeners = new Map<string, Array<(event: Event) => void>>();
const target: PrevisCanvasTarget = {
addEventListener: (type, listener) => {
listeners.set(type, [...(listeners.get(type) || []), listener]);
},
removeEventListener: (type, listener) => {
listeners.set(
type,
(listeners.get(type) || []).filter((item) => item !== listener),
);
},
};
return {
target,
emit: (type: string, event: Partial<Event> = {}) => (listeners.get(type) || []).forEach((listener) => listener(event as Event)),
count: () => [...listeners.values()].reduce((sum, items) => sum + items.length, 0),
};
}
describe("加载状态机:error -> retry -> ready(D3 回归)", () => {
test("初始为 loading", () => {
expect(previsLoadInitial).toEqual({ phase: "loading", generation: 0 });
});
test("失败后可 retry,retry 递增 generation 并回到 loading", () => {
const failed = reducePrevisLoad(previsLoadInitial, { type: "failed", generation: 0 });
expect(failed.phase).toBe("error");
const retried = reducePrevisLoad(failed, { type: "retry" });
expect(retried).toEqual({ phase: "loading", generation: 1 });
const ready = reducePrevisLoad(retried, { type: "loaded", generation: 1 });
expect(ready).toEqual({ phase: "ready", generation: 1 });
});
test("旧 generation 的晚到成功回调被忽略", () => {
const retried = reducePrevisLoad(reducePrevisLoad(previsLoadInitial, { type: "failed", generation: 0 }), { type: "retry" });
expect(reducePrevisLoad(retried, { type: "loaded", generation: 0 })).toBe(retried);
});
test("旧 generation 的晚到失败回调不会污染新一代", () => {
const retried = reducePrevisLoad(previsLoadInitial, { type: "retry" });
const ready = reducePrevisLoad(retried, { type: "loaded", generation: 1 });
expect(reducePrevisLoad(ready, { type: "failed", generation: 0 })).toBe(ready);
expect(ready.phase).toBe("ready");
});
test("start 不改变 generation,只回到 loading", () => {
const ready = reducePrevisLoad(previsLoadInitial, { type: "loaded", generation: 0 });
expect(reducePrevisLoad(ready, { type: "start" })).toEqual({ phase: "loading", generation: 0 });
});
test("连续 retry 单调递增,旧回调始终被拒", () => {
let state = previsLoadInitial;
for (let index = 0; index < 3; index += 1) state = reducePrevisLoad(state, { type: "retry" });
expect(state.generation).toBe(3);
[0, 1, 2].forEach((generation) => expect(reducePrevisLoad(state, { type: "loaded", generation })).toBe(state));
expect(reducePrevisLoad(state, { type: "loaded", generation: 3 }).phase).toBe("ready");
});
});
describe("WebGL 上下文监听(D2 回归)", () => {
test("lost 必须 preventDefault,否则浏览器不补发 restored", () => {
const canvas = fakeCanvas();
const events: string[] = [];
installPrevisContextListeners(canvas.target, { onLost: () => events.push("lost"), onRestored: () => events.push("restored") });
let prevented = 0;
canvas.emit("webglcontextlost", {
preventDefault: () => {
prevented += 1;
},
} as unknown as Event);
expect(prevented).toBe(1);
expect(events).toEqual(["lost"]);
});
test("restored 会回调恢复处理", () => {
const canvas = fakeCanvas();
const events: string[] = [];
installPrevisContextListeners(canvas.target, { onLost: () => events.push("lost"), onRestored: () => events.push("restored") });
canvas.emit("webglcontextlost", { preventDefault: () => undefined } as unknown as Event);
canvas.emit("webglcontextrestored");
expect(events).toEqual(["lost", "restored"]);
});
test("disposer 精确摘除全部监听", () => {
const canvas = fakeCanvas();
const events: string[] = [];
const dispose = installPrevisContextListeners(canvas.target, { onLost: () => events.push("lost"), onRestored: () => events.push("restored") });
expect(canvas.count()).toBe(2);
dispose();
expect(canvas.count()).toBe(0);
canvas.emit("webglcontextlost", { preventDefault: () => undefined } as unknown as Event);
canvas.emit("webglcontextrestored");
expect(events).toEqual([]);
});
test("重复安装/摘除不互相干扰(renderer 重建场景)", () => {
const first = fakeCanvas();
const second = fakeCanvas();
const events: string[] = [];
const disposeFirst = installPrevisContextListeners(first.target, { onLost: () => events.push("first-lost"), onRestored: () => undefined });
disposeFirst();
installPrevisContextListeners(second.target, { onLost: () => events.push("second-lost"), onRestored: () => undefined });
first.emit("webglcontextlost", { preventDefault: () => undefined } as unknown as Event);
second.emit("webglcontextlost", { preventDefault: () => undefined } as unknown as Event);
expect(events).toEqual(["second-lost"]);
});
});
describe("失败登记表不残留陈旧条目(#4 回归)", () => {
const retryA = () => undefined;
const retryB = () => undefined;
test("error 登记 -> unmounted 注销 -> 空表", () => {
const registered = upsertPrevisFailedLoad({}, "a", "error", retryA);
expect(Object.keys(registered)).toEqual(["a"]);
expect(upsertPrevisFailedLoad(registered, "a", "unmounted", retryA)).toEqual({});
});
test("retry 回到 loading 会移除该条目", () => {
const registered = upsertPrevisFailedLoad({}, "a", "error", retryA);
expect(upsertPrevisFailedLoad(registered, "a", "loading", retryA)).toEqual({});
});
test("ready 也会移除", () => {
const registered = upsertPrevisFailedLoad({}, "a", "error", retryA);
expect(upsertPrevisFailedLoad(registered, "a", "ready", retryA)).toEqual({});
});
test("注销一个对象不影响相邻对象", () => {
let table: PrevisFailedLoads = {};
table = upsertPrevisFailedLoad(table, "a", "error", retryA);
table = upsertPrevisFailedLoad(table, "b", "error", retryB);
expect(Object.keys(table).sort()).toEqual(["a", "b"]);
const afterUnmount = upsertPrevisFailedLoad(table, "a", "unmounted", retryA);
expect(Object.keys(afterUnmount)).toEqual(["b"]);
expect(afterUnmount.b).toBe(retryB);
});
test("重复 error 不产生新引用(避免无谓重渲染)", () => {
const registered = upsertPrevisFailedLoad({}, "a", "error", retryA);
expect(upsertPrevisFailedLoad(registered, "a", "error", retryA)).toBe(registered);
});
test("removePrevisFailedLoad 对不存在的 id 是同引用空操作", () => {
const table = upsertPrevisFailedLoad({}, "a", "error", retryA);
expect(removePrevisFailedLoad(table, "missing")).toBe(table);
expect(removePrevisFailedLoad(table, "a")).toEqual({});
});
});
describe("capture 可用性随上下文丢失/恢复(#7 回归)", () => {
test("未登记时不可用", () => {
expect(previsCaptureUsable(previsCaptureInitial)).toBe(false);
});
test("登记后可用;lost 期间立即不可用", () => {
const registered = reducePrevisCapture(previsCaptureInitial, "register");
expect(previsCaptureUsable(registered)).toBe(true);
const lost = reducePrevisCapture(registered, "lost");
expect(previsCaptureUsable(lost)).toBe(false);
});
test("restored 之后必须重新登记才可用", () => {
const lost = reducePrevisCapture(reducePrevisCapture(previsCaptureInitial, "register"), "lost");
const restored = reducePrevisCapture(lost, "restored");
expect(restored.contextLost).toBe(false);
expect(previsCaptureUsable(restored)).toBe(false);
expect(previsCaptureUsable(reducePrevisCapture(restored, "register"))).toBe(true);
});
test("reset(手动重建)回到不可用初态", () => {
const registered = reducePrevisCapture(previsCaptureInitial, "register");
expect(reducePrevisCapture(registered, "reset")).toEqual(previsCaptureInitial);
});
test("restorePrevisCapture 走完整序列后 registered=true / contextLost=false(#7 生产接线回归)", () => {
// 用生产 handler 调用的同一个 helper 驱动真实 reducer,
// 避免「reducer 单测过、生产漏掉 register」这种脱节。
let state = reducePrevisCapture(reducePrevisCapture(previsCaptureInitial, "register"), "lost");
expect(previsCaptureUsable(state)).toBe(false);
const contexts: string[] = [];
let invalidated = 0;
restorePrevisCapture<string>({
readContext: () => "renderer-after-restore",
onAvailability: (event) => {
state = reducePrevisCapture(state, event);
},
onRegister: (context) => {
contexts.push(context);
state = reducePrevisCapture(state, "register");
},
invalidate: () => {
invalidated += 1;
},
});
expect(contexts).toEqual(["renderer-after-restore"]);
expect(state).toEqual({ registered: true, contextLost: false });
expect(previsCaptureUsable(state)).toBe(true);
expect(invalidated).toBe(1);
});
test("序列顺序固定:先复位可用性,再登记,最后重绘", () => {
const order: string[] = [];
restorePrevisCapture<string>({
readContext: () => "ctx",
onAvailability: () => order.push("availability"),
onRegister: () => order.push("register"),
invalidate: () => order.push("invalidate"),
});
expect(order).toEqual(["availability", "register", "invalidate"]);
});
test("只走 availability 而漏掉 register 时仍不可用(说明缺陷形态)", () => {
let state = reducePrevisCapture(reducePrevisCapture(previsCaptureInitial, "register"), "lost");
state = reducePrevisCapture(state, "restored");
expect(state.contextLost).toBe(false);
expect(previsCaptureUsable(state)).toBe(false);
});
test("readContext 在登记时被求值一次,拿到的是恢复后的新引用", () => {
let reads = 0;
const seen: string[] = [];
restorePrevisCapture<string>({
readContext: () => {
reads += 1;
return `ctx-${reads}`;
},
onAvailability: () => undefined,
onRegister: (context) => seen.push(context),
});
expect(reads).toBe(1);
expect(seen).toEqual(["ctx-1"]);
});
});
describe("capture 释放路径(子树卸载 / boundary 捕获)", () => {
/** 与生产接线同构:captureContext ref + capture reducer,只是不挂 React。 */
function harness() {
let context: string | null = null;
let state = previsCaptureInitial;
return {
usable: () => previsCaptureUsable(state) && context !== null,
context: () => context,
register: (next: string) => {
context = next;
state = reducePrevisCapture(state, "register");
},
release: () =>
releasePrevisCapture({
clearContext: () => {
context = null;
},
onAvailability: (event) => {
state = reducePrevisCapture(state, event);
},
}),
state: () => state,
};
}
test("release 后 context 被清空且 capture 不可用", () => {
const h = harness();
h.register("renderer-1");
expect(h.usable()).toBe(true);
h.release();
expect(h.context()).toBeNull();
expect(h.state()).toEqual(previsCaptureInitial);
expect(h.usable()).toBe(false);
});
test("release 之后仍能由新 renderer 重新 register(重试路径不被堵死)", () => {
const h = harness();
h.register("renderer-1");
h.release();
h.register("renderer-2");
expect(h.context()).toBe("renderer-2");
expect(h.usable()).toBe(true);
});
test("重复 release 幂等,且 reset 在初态返回同一引用(避免无限 render)", () => {
const h = harness();
h.register("renderer-1");
h.release();
const first = h.state();
h.release();
expect(h.state()).toBe(first);
expect(h.usable()).toBe(false);
});
test("上下文丢失期间 release 同样归零,不残留 contextLost", () => {
const h = harness();
h.register("renderer-1");
const lost = reducePrevisCapture(h.state(), "lost");
expect(previsCaptureUsable(lost)).toBe(false);
h.release();
expect(h.state()).toEqual(previsCaptureInitial);
expect(h.context()).toBeNull();
});
});
describe("展示身份在 render 阶段屏蔽旧资源(#2 回归)", () => {
test("identity 由 generation 与解析输入共同决定", () => {
const base = { generation: 0, url: "a.glb", storageKey: "k", kind: "model" };
expect(previsLoadIdentity(base)).toBe(previsLoadIdentity({ ...base }));
expect(previsLoadIdentity({ ...base, generation: 1 })).not.toBe(previsLoadIdentity(base));
expect(previsLoadIdentity({ ...base, url: "b.glb" })).not.toBe(previsLoadIdentity(base));
expect(previsLoadIdentity({ ...base, storageKey: "k2" })).not.toBe(previsLoadIdentity(base));
expect(previsLoadIdentity({ ...base, kind: "actor" })).not.toBe(previsLoadIdentity(base));
});
test("换 URL 的那一次 render 立刻不再交出旧资源", () => {
const oldIdentity = previsLoadIdentity({ generation: 0, url: "old.glb" });
const loaded = { identity: oldIdentity, value: "old-model" };
// 仍是同一 identity:正常展示。
expect(resolvePrevisDisplay(loaded, oldIdentity)).toBe("old-model");
// URL 变化后的第一次 render:identity 不匹配,立即当作无资源(此时 cleanup 还没 dispose)。
expect(resolvePrevisDisplay(loaded, previsLoadIdentity({ generation: 0, url: "new.glb" }))).toBeNull();
});
test("retry 递增 generation 后旧资源同样被屏蔽", () => {
const loaded = { identity: previsLoadIdentity({ generation: 0, url: "a.glb" }), value: "gen0" };
expect(resolvePrevisDisplay(loaded, previsLoadIdentity({ generation: 1, url: "a.glb" }))).toBeNull();
});
test("空状态安全", () => {
expect(resolvePrevisDisplay(null, previsLoadIdentity({ generation: 0 }))).toBeNull();
});
});