Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts
suzmii 9121e89798 资源画布新增组织操作撤销重做(WP4)
新增 features/resource-canvas/resourceCanvasHistoryModel:纯函数有界快照栈(capture/push/undo/redo/clear + 只回写差异位置),快照只含资源卡布局坐标,不回滚素材内容

新增 tests/resourceCanvasHistoryModel.test.ts 覆盖排序稳定、相同快照不入栈、栈上限、push 清空重做分支、撤销重做互逆、空栈返回 null、差异回写与已删除资源不重建

资源卡拖动持久化成功前先落快照,撤销/重做复用现役 commitPosition 手动 CAS 写链

画布缩放集群新增撤销/重做按钮(不可用时禁用),并支持 Ctrl/Cmd+Z 与 Ctrl/Cmd+Shift+Z、Ctrl+Y(输入框内不拦截)

项目切换时清空布局历史,避免跨项目回滚
2026-09-10 15:43:03 +08:00

167 lines
5.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
canRedoResourceCanvasHistory,
canUndoResourceCanvasHistory,
captureResourceCanvasSnapshot,
clearResourceCanvasHistory,
createResourceCanvasHistory,
MAX_RESOURCE_CANVAS_HISTORY_STEPS,
pushResourceCanvasHistory,
redoResourceCanvasHistory,
resolveResourceCanvasRestoreEntries,
undoResourceCanvasHistory,
} from '../src/features/resource-canvas/resourceCanvasHistoryModel';
function positions(
entries: Array<[string, number, number, boolean?]>,
): ReturnType<typeof captureResourceCanvasSnapshot>['entries'] {
return entries.map(([resourceId, x, y, manuallyPlaced = true]) => ({
resourceId,
section: 'document',
x,
y,
manuallyPlaced,
}));
}
describe('resource canvas history model', () => {
it('按 resourceId 排序落快照,map 顺序不影响相等判定', () => {
const left = captureResourceCanvasSnapshot(
positions([
['asset:b', 2, 2],
['asset:a', 1, 1],
]),
);
const right = captureResourceCanvasSnapshot(
positions([
['asset:a', 1, 1],
['asset:b', 2, 2],
]),
);
expect(left.entries.map((entry) => entry.resourceId)).toEqual([
'asset:a',
'asset:b',
]);
expect(left).toEqual(right);
});
it('push 连续相同快照不产生新历史条目', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([['asset:a', 1, 1]]),
);
const first = pushResourceCanvasHistory(createResourceCanvasHistory(), {
label: '移动卡片',
snapshot,
});
const second = pushResourceCanvasHistory(first, {
label: '移动卡片',
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
});
expect(first.undoStack).toHaveLength(1);
expect(second).toBe(first);
});
it('push 后重做分支失效,撤回栈有界', () => {
let history = createResourceCanvasHistory();
for (
let index = 0;
index < MAX_RESOURCE_CANVAS_HISTORY_STEPS + 5;
index += 1
) {
history = pushResourceCanvasHistory(history, {
label: `移动 ${index}`,
snapshot: captureResourceCanvasSnapshot(
positions([['asset:a', index, index]]),
),
});
}
expect(history.undoStack).toHaveLength(MAX_RESOURCE_CANVAS_HISTORY_STEPS);
expect(history.undoStack[0]?.label).toBe('移动 5');
const undone = undoResourceCanvasHistory(
history,
captureResourceCanvasSnapshot(positions([['asset:a', 99, 99]])),
)!;
expect(canRedoResourceCanvasHistory(undone.history)).toBe(true);
const pushed = pushResourceCanvasHistory(undone.history, {
label: '新的移动',
snapshot: captureResourceCanvasSnapshot(
positions([['asset:a', 120, 120]]),
),
});
expect(canRedoResourceCanvasHistory(pushed)).toBe(false);
});
it('撤销与重做互为逆操作,并回填当前快照', () => {
const moved = captureResourceCanvasSnapshot(
positions([['asset:a', 10, 20]]),
);
let history = pushResourceCanvasHistory(createResourceCanvasHistory(), {
label: '移动卡片',
snapshot: moved,
});
const current = captureResourceCanvasSnapshot(
positions([['asset:a', 60, 70]]),
);
const undone = undoResourceCanvasHistory(history, current)!;
expect(undone.snapshot).toEqual(moved);
expect(undone.label).toBe('移动卡片');
expect(canUndoResourceCanvasHistory(undone.history)).toBe(false);
const redone = redoResourceCanvasHistory(undone.history, moved)!;
expect(redone.snapshot).toEqual(current);
expect(canRedoResourceCanvasHistory(redone.history)).toBe(false);
expect(canUndoResourceCanvasHistory(redone.history)).toBe(true);
history = redone.history;
expect(history.redoStack).toHaveLength(0);
});
it('空历史撤销/重做返回 null,清空后两个栈都为空', () => {
const empty = createResourceCanvasHistory();
expect(undoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
expect(redoResourceCanvasHistory(empty, { entries: [] })).toBeNull();
const history = pushResourceCanvasHistory(empty, {
label: '移动卡片',
snapshot: captureResourceCanvasSnapshot(positions([['asset:a', 1, 1]])),
});
const cleared = clearResourceCanvasHistory(history);
expect(cleared.undoStack).toHaveLength(0);
expect(cleared.redoStack).toHaveLength(0);
expect(clearResourceCanvasHistory(cleared)).toBe(cleared);
});
it('只把与当前不一致的位置写回后端', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([
['asset:a', 10, 20],
['asset:b', 30, 40],
]),
);
const restore = resolveResourceCanvasRestoreEntries(
snapshot,
positions([
['asset:a', 10, 20],
['asset:b', 99, 98],
['asset:c', 1, 1],
]),
);
expect(restore.map((entry) => entry.resourceId)).toEqual(['asset:b']);
});
it('快照里不存在的资源不会在撤销时被新建出来', () => {
const snapshot = captureResourceCanvasSnapshot(
positions([['asset:deleted', 10, 20]]),
);
expect(
resolveResourceCanvasRestoreEntries(snapshot, positions([])),
).toEqual([]);
});
});