9121e89798
新增 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(输入框内不拦截) 项目切换时清空布局历史,避免跨项目回滚
167 lines
5.2 KiB
TypeScript
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([]);
|
|
});
|
|
});
|