fix(canvas): 预演合并 - 场景时间戳采用服务端快照,带 id 数组按条目合并
每次预演变更都会改 updatedAt,不再把它当成用户编辑冲突;带 id 的对象数组按条目合并,仅刷新场景时保留本地视口。
This commit is contained in:
@@ -24,6 +24,26 @@ function equal(a: unknown, b: unknown): boolean {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => equal(item, b[index]));
|
||||
}
|
||||
|
||||
function identifiedArray(value: unknown): value is Record<string, unknown>[] {
|
||||
return Array.isArray(value) && value.length > 0 && value.every((item) => record(item) && typeof item.id === "string" && item.id.length > 0);
|
||||
}
|
||||
|
||||
function mergeIdentifiedArray(current: unknown, before: unknown, after: unknown): unknown[] | undefined {
|
||||
const values = [current, before, after];
|
||||
if (!values.every((value) => Array.isArray(value))) return undefined;
|
||||
const arrays = values as unknown[][];
|
||||
if (!arrays.some((items) => items.length > 0) || !arrays.every((items) => items.length === 0 || identifiedArray(items))) return undefined;
|
||||
|
||||
const maps = arrays.map((items) => new Map(items.map((item) => [(item as Record<string, unknown>).id as string, item as Record<string, unknown>])));
|
||||
const ids = [...new Set(arrays.flatMap((items) => items.map((item) => (item as Record<string, unknown>).id as string)))];
|
||||
const merged: unknown[] = [];
|
||||
for (const id of ids) {
|
||||
const value = mergeThreeWayValue(maps[0].get(id), maps[1].get(id), maps[2].get(id));
|
||||
if (value !== undefined) merged.push(value);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Three-way merge: untouched local fields (e.g. a drag or prompt edit) survive.
|
||||
// A concurrently changed field or a deleted node is a conflict, never an overwrite.
|
||||
export function mergeThreeWayValue(current: unknown, before: unknown, after: unknown): unknown {
|
||||
@@ -33,12 +53,23 @@ export function mergeThreeWayValue(current: unknown, before: unknown, after: unk
|
||||
const next = { ...current };
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("无效的画布增量字段");
|
||||
const value = mergeThreeWayValue(current[key], before[key], after[key]);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = mergeThreeWayValue(current[key], before[key], after[key]);
|
||||
} catch (error) {
|
||||
// Scene timestamps are persistence bookkeeping emitted for every
|
||||
// Previs mutation, not user-editable content. If both sides
|
||||
// touched only that timestamp, the server snapshot wins.
|
||||
if (key !== "updatedAt") throw error;
|
||||
value = after[key];
|
||||
}
|
||||
if (value === undefined) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const mergedArray = mergeIdentifiedArray(current, before, after);
|
||||
if (mergedArray !== undefined) return mergedArray;
|
||||
throw new Error("Agent 画布增量与本地内容冲突,需要校准;已保留本地编辑");
|
||||
}
|
||||
|
||||
|
||||
@@ -68,12 +68,60 @@ test("task progress ahead of the server checkpoint still accepts terminal state"
|
||||
expect(isCanvasNodeGenerating(result.nodes[0])).toBe(false);
|
||||
});
|
||||
|
||||
test("full Agent refresh removes unchanged nodes but preserves conflicting local edits", () => {
|
||||
const remaining = { ...node, id: "remaining", metadata: {} };
|
||||
const previous = { ...project, nodes: [node, remaining] };
|
||||
const incoming = { ...previous, nodes: [remaining] };
|
||||
expect(mergeAgentCanvasEditor(previous, incoming, previous.nodes, []).nodes).toEqual([remaining]);
|
||||
const local = { ...node, title: "Unsaved local title" };
|
||||
expect(() => mergeAgentCanvasEditor(previous, incoming, [local, remaining], [])).toThrow("冲突");
|
||||
expect(local.title).toBe("Unsaved local title");
|
||||
test("full Previs refresh merges scene bookkeeping and preserves the local viewport", () => {
|
||||
const scene = {
|
||||
id: "scene-1",
|
||||
version: 1 as const,
|
||||
title: "镜头",
|
||||
background: "#111111",
|
||||
environmentIntensity: 0.7,
|
||||
environment: { mode: "color" as const },
|
||||
gridVisible: true,
|
||||
objects: [],
|
||||
cameras: [],
|
||||
lights: [],
|
||||
shots: [],
|
||||
activeShotId: "shot-1",
|
||||
createdAt: "2026-09-13T00:00:00Z",
|
||||
updatedAt: "2026-09-13T00:00:00Z",
|
||||
};
|
||||
const previous = { ...project, previsScenes: [scene] };
|
||||
const incoming = {
|
||||
...previous,
|
||||
revision: 2,
|
||||
updatedAt: "2026-09-13T12:00:00Z",
|
||||
previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }],
|
||||
};
|
||||
const local = {
|
||||
...previous,
|
||||
viewport: { x: -480, y: -220, k: 0.72 },
|
||||
previsScenes: [{ ...scene, updatedAt: "2026-09-13T11:59:00Z" }],
|
||||
};
|
||||
const merged = mergeAgentCanvasEditor(previous, incoming, local.nodes, local.connections, local);
|
||||
expect(merged.previsScenes[0].objects).toEqual(incoming.previsScenes[0].objects);
|
||||
expect(merged.previsScenes[0].updatedAt).toBe(incoming.previsScenes[0].updatedAt);
|
||||
expect(merged.viewport).toEqual(local.viewport);
|
||||
});
|
||||
|
||||
test("full Previs refresh still rejects a same-field scene edit", () => {
|
||||
const scene = {
|
||||
id: "scene-1",
|
||||
version: 1 as const,
|
||||
title: "镜头",
|
||||
background: "#111111",
|
||||
environmentIntensity: 0.7,
|
||||
environment: { mode: "color" as const },
|
||||
gridVisible: true,
|
||||
objects: [],
|
||||
cameras: [],
|
||||
lights: [],
|
||||
shots: [],
|
||||
activeShotId: "shot-1",
|
||||
createdAt: "2026-09-13T00:00:00Z",
|
||||
updatedAt: "2026-09-13T00:00:00Z",
|
||||
};
|
||||
const previous = { ...project, previsScenes: [scene] };
|
||||
const incoming = { ...previous, revision: 2, previsScenes: [{ ...scene, title: "云端镜头", updatedAt: "2026-09-13T12:00:00Z" }] };
|
||||
const local = { ...previous, previsScenes: [{ ...scene, title: "本地镜头", updatedAt: "2026-09-13T11:59:00Z" }] };
|
||||
expect(() => mergeAgentCanvasEditor(previous, incoming, local.nodes, local.connections, local)).toThrow("冲突");
|
||||
});
|
||||
|
||||
@@ -113,6 +113,42 @@ test("automatic focus and panning do not block Agent updates or reset the local
|
||||
expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
|
||||
});
|
||||
|
||||
test("refresh-only Previs mutations do not conflict with an editor-only viewport change", async () => {
|
||||
const scene = {
|
||||
id: "scene-1",
|
||||
version: 1 as const,
|
||||
title: "镜头",
|
||||
background: "#111111",
|
||||
environmentIntensity: 0.7,
|
||||
environment: { mode: "color" as const },
|
||||
gridVisible: true,
|
||||
objects: [],
|
||||
cameras: [],
|
||||
lights: [],
|
||||
shots: [],
|
||||
activeShotId: "shot-1",
|
||||
createdAt: "2026-09-13T00:00:00Z",
|
||||
updatedAt: "2026-09-13T00:00:00Z",
|
||||
};
|
||||
const local = { ...initial, previsScenes: [scene] };
|
||||
local.remoteContentHash = await canvasContentHash(local);
|
||||
const remoteWithPrevis = {
|
||||
...local,
|
||||
revision: 2,
|
||||
previsScenes: [{ ...scene, updatedAt: "2026-09-13T12:00:00Z", objects: [{ id: "actor-1", name: "Agent 演员" }] }],
|
||||
};
|
||||
useCanvasStore.setState({ projects: [local] });
|
||||
await initializeRemoteUserDataSession("agent-user");
|
||||
const mock = spyOn(http, "get").mockResolvedValue({ project: remoteWithPrevis });
|
||||
restore = () => mock.mockRestore();
|
||||
const viewport = { x: -640, y: -280, k: 0.68 };
|
||||
useCanvasStore.setState({ projects: [{ ...local, viewport, updatedAt: "2026-09-13T12:01:00Z" }] });
|
||||
await refreshCanvasAfterAgent(local.id);
|
||||
expect(useCanvasStore.getState().projects[0].viewport).toEqual(viewport);
|
||||
expect(useCanvasStore.getState().projects[0].previsScenes).toEqual(remoteWithPrevis.previsScenes);
|
||||
expect(useSyncProgressStore.getState().syncingProjects[local.id].phase).toBe("done");
|
||||
});
|
||||
|
||||
test("replayed completion events do not reapply unchanged canvas nodes", async () => {
|
||||
await setup();
|
||||
let deliveries = 0;
|
||||
|
||||
Reference in New Issue
Block a user