Files
Genarrative/src/components/image-editor/useCanvasHistory.test.tsx
T
menghao 03a4410272
Project CI / Repository checks (push) Successful in 1m5s
Project CI / Frontend tests (push) Successful in 3m20s
Project CI / Backend tests (push) Successful in 4m1s
Project CI / Native shell tests (push) Failing after 11m56s
game agent无限画布开发 (#136)
主要工作是共享同一套“画布内核与通用 UI 源码”,网站和 Tauri 只分别实现宿主适配层。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/136
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-12 10:54:16 +08:00

597 lines
18 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, render, screen } from '@testing-library/react';
import { useRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { useCanvasHistory } from './useCanvasHistory';
function createLayer(id: string, x: number): CanvasLayer {
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${id}`,
x,
y: 20,
width: 100,
height: 80,
originalWidth: 100,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
};
}
function HistoryHarness({
onClearDrag,
persistRestoredAssetKind = () => {},
}: {
onClearDrag: () => void;
persistRestoredAssetKind?: (layer: CanvasLayer) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([
createLayer('first', 10),
]);
const [viewport, setViewport] = useState<CanvasViewport>({
x: 1,
y: 2,
scale: 1,
});
const [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>({
id: 'dialog-active',
mode: 'generate',
prompt: 'active prompt',
status: 'idle',
imageSize: '1K',
placeholder: {
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
});
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
CanvasGenerationDialogState[]
>([
{
id: 'dialog-inactive',
mode: 'character',
prompt: 'archived prompt',
status: 'idle',
placeholder: {
x: 30,
y: 40,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
},
},
]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
'first',
);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(['first']);
const [lastHistoryStatus, setLastHistoryStatus] = useState('empty');
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const generateDialogRef = useRef(generateDialog);
const inactiveGenerateDialogsRef = useRef(inactiveGenerateDialogs);
const selectedLayerIdRef = useRef(selectedLayerId);
const selectedLayerIdsRef = useRef(selectedLayerIds);
layersRef.current = layers;
viewportRef.current = viewport;
generateDialogRef.current = generateDialog;
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
const history = useCanvasHistory({
refs: {
layersRef,
viewportRef,
generateDialogRef,
inactiveGenerateDialogsRef,
selectedLayerIdRef,
selectedLayerIdsRef,
},
setters: {
setLayers,
setViewport,
setGenerateDialog,
setInactiveGenerateDialogs,
setSelectedLayerId,
setSelectedLayerIds,
},
resetters: {
setHoveredLayerId: () => {},
setMetadataLayer: () => {},
resetCanvasInteractionState: onClearDrag,
},
effects: { persistRestoredAssetKind },
});
return (
<div>
<span data-testid="layers">
{layers.map((layer) => `${layer.id}:${layer.x}`).join(',')}
</span>
<span data-testid="layer-size">
{layers.map((layer) => `${layer.width}x${layer.height}`).join(',')}
</span>
<span data-testid="layer-relations">
{layers
.map(
(layer) =>
`${layer.resourceId}:${layer.sourceResourceId ?? '-'}:${layer.sourceAssetId ?? '-'}`,
)
.join(',')}
</span>
<span data-testid="layer-asset-kind">
{layers
.map((layer) => `${layer.resourceId}:${layer.assetKind ?? '-'}`)
.join(',')}
</span>
<span data-testid="viewport">
{viewport.x},{viewport.y},{viewport.scale}
</span>
<span data-testid="dialog">{generateDialog?.prompt ?? '-'}</span>
<span data-testid="dialog-state">
{generateDialog
? `${generateDialog.status}:${generateDialog.placeholder?.x ?? '-'}:${generateDialog.generatedLayerId ?? '-'}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationStartedAt ?? '-'}`
: '-'}
</span>
<span data-testid="dialog-size">
{generateDialog
? `${generateDialog.imageSize ?? '-'}:${generateDialog.placeholder?.width ?? '-'}:${generateDialog.placeholder?.height ?? '-'}:${generateDialog.placeholder?.originalWidth ?? '-'}:${generateDialog.placeholder?.originalHeight ?? '-'}`
: '-'}
</span>
<span data-testid="inactive">
{inactiveGenerateDialogs.map((dialog) => dialog.prompt).join(',')}
</span>
<span data-testid="selection">{selectedLayerIds.join(',')}</span>
<span data-testid="can-undo">{String(history.canUndo)}</span>
<span data-testid="can-redo">{String(history.canRedo)}</span>
<span data-testid="history-status">{lastHistoryStatus}</span>
<span data-testid="history-version">{history.historyVersion}</span>
<button
type="button"
onClick={() => {
history.captureCanvasHistory({ type: 'move-image', count: 1 });
}}
>
capture
</button>
<button
type="button"
onClick={() => {
history.captureCanvasHistory({ type: 'resize-image', count: 1 });
}}
>
capture-resize
</button>
<button
type="button"
onClick={() => {
history.captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
});
}}
>
capture-asset-kind
</button>
<button
type="button"
onClick={() => {
setLayers([createLayer('first', 90)]);
setViewport({ x: 9, y: 8, scale: 2 });
setSelectedLayerId('first');
setSelectedLayerIds(['first']);
}}
>
mutate
</button>
<button
type="button"
onClick={() =>
setLayers([
{
...createLayer('first', 90),
resourceId: 'persisted-resource-first',
sourceResourceId: 'source-resource-late',
sourceAssetId: 'source-asset-late',
objectKey: 'uploads/first.png',
},
])
}
>
persist-and-move
</button>
<button type="button" onClick={() => setLayers([])}>
delete
</button>
<button
type="button"
onClick={() =>
setLayers((currentLayers) =>
currentLayers.map((layer) => ({
...layer,
width: 240,
height: 160,
})),
)
}
>
resize
</button>
<button
type="button"
onClick={() =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === 'first'
? {
...layer,
resourceId: 'resource-first-icon',
assetKind: 'icon',
}
: layer,
),
)
}
>
change-asset-kind
</button>
<button
type="button"
onClick={() =>
history.discardCanvasHistoryEntriesContainingLayer(
(layer) => layer.id === 'first',
)
}
>
discard-first-history
</button>
<button
type="button"
onClick={() =>
history.discardCanvasHistoryEntriesContainingLayer(
(layer) => layer.id === 'missing',
)
}
>
discard-missing-history
</button>
<button
type="button"
onClick={() =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: 'current prompt',
status: 'generating',
generatedLayerId: 'generated-layer-current',
generationReferences: [
{
id: 'current-reference',
label: 'current reference',
src: '/current-reference.png',
},
],
generationStartedAt: 100,
imageSize: '2K',
placeholder: currentDialog.placeholder
? {
...currentDialog.placeholder,
x: 90,
width: 640,
height: 480,
originalWidth: 640,
originalHeight: 480,
}
: undefined,
}
: currentDialog,
)
}
>
advance-dialog
</button>
<button type="button" onClick={() => setGenerateDialog(null)}>
delete-dialog
</button>
<button
type="button"
onClick={() => {
setLastHistoryStatus(history.undoCanvasChange().status);
}}
>
undo
</button>
<button
type="button"
onClick={() => {
setLastHistoryStatus(history.redoCanvasChange().status);
}}
>
redo
</button>
</div>
);
}
describe('useCanvasHistory', () => {
it('restores shared layer bounds for resize history', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture-resize' }).click();
screen.getByRole('button', { name: 'resize' }).click();
});
expect(screen.getByTestId('layer-size').textContent).toBe('240x160');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layer-size').textContent).toBe('100x80');
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layer-size').textContent).toBe('240x160');
});
it('captures, undoes, and redoes safe canvas history snapshots', () => {
const clearDragState = vi.fn();
render(<HistoryHarness onClearDrag={clearDragState} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'mutate' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:90');
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('viewport').textContent).toBe('1,2,1');
expect(screen.getByTestId('dialog').textContent).toBe('active prompt');
expect(screen.getByTestId('inactive').textContent).toBe('archived prompt');
expect(screen.getByTestId('selection').textContent).toBe('first');
expect(screen.getByTestId('can-redo').textContent).toBe('true');
expect(clearDragState).toHaveBeenCalledTimes(1);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:90');
expect(screen.getByTestId('viewport').textContent).toBe('9,8,2');
expect(screen.getByTestId('can-redo').textContent).toBe('false');
expect(screen.getByTestId('can-undo').textContent).toBe('true');
});
it('blocks redo when replaying a deletion would remove canvas content', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:10');
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('blocked');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('can-redo').textContent).toBe('true');
});
it('clears redo history after a new canvas edit is captured', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'mutate' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('false');
});
it('undoes dialog movement without reverting its generation lifecycle', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'advance-dialog' }).click();
});
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:90:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('current prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:10:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('current prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'generating:90:generated-layer-current:current reference:100',
);
expect(screen.getByTestId('dialog-size').textContent).toBe(
'2K:640:480:640:480',
);
});
it('restores a generation dialog that was deleted after capture', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
screen.getByRole('button', { name: 'delete-dialog' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('dialog').textContent).toBe('active prompt');
expect(screen.getByTestId('dialog-state').textContent).toBe(
'idle:10:-:-:-',
);
});
it('undoes movement after source asset relation ids arrive asynchronously', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'persist-and-move' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('first:90');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('success');
expect(screen.getByTestId('layers').textContent).toBe('first:10');
expect(screen.getByTestId('layer-relations').textContent).toBe(
'persisted-resource-first:source-resource-late:source-asset-late',
);
});
it('discards undo and redo entries whose snapshots contain a deleted asset layer', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
act(() => {
screen.getByRole('button', { name: 'mutate' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('can-redo').textContent).toBe('true');
act(() => {
screen.getByRole('button', { name: 'discard-first-history' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('false');
expect(screen.getByTestId('can-redo').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('history-status').textContent).toBe('empty');
});
it('does not update history state when an invalidation matcher misses', () => {
render(<HistoryHarness onClearDrag={() => {}} />);
act(() => {
screen.getByRole('button', { name: 'capture' }).click();
});
const versionBeforeDiscard =
screen.getByTestId('history-version').textContent;
act(() => {
screen.getByRole('button', { name: 'discard-missing-history' }).click();
});
expect(screen.getByTestId('can-undo').textContent).toBe('true');
expect(screen.getByTestId('history-version').textContent).toBe(
versionBeforeDiscard,
);
});
it('persists asset kind changes restored by undo and redo', () => {
const persistRestoredAssetKind = vi.fn();
render(
<HistoryHarness
onClearDrag={() => {}}
persistRestoredAssetKind={persistRestoredAssetKind}
/>,
);
act(() => {
screen.getByRole('button', { name: 'capture-asset-kind' }).click();
});
act(() => {
screen.getByRole('button', { name: 'change-asset-kind' }).click();
});
act(() => {
screen.getByRole('button', { name: 'undo' }).click();
});
expect(screen.getByTestId('layer-asset-kind').textContent).toBe(
'resource-first-icon:-',
);
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
expect.objectContaining({
id: 'first',
resourceId: 'resource-first-icon',
assetKind: undefined,
}),
expect.any(Number),
);
act(() => {
screen.getByRole('button', { name: 'redo' }).click();
});
expect(screen.getByTestId('layer-asset-kind').textContent).toBe(
'resource-first-icon:icon',
);
expect(persistRestoredAssetKind).toHaveBeenLastCalledWith(
expect.objectContaining({ assetKind: 'icon' }),
persistRestoredAssetKind.mock.calls[0]?.[1],
);
expect(persistRestoredAssetKind).toHaveBeenCalledTimes(2);
});
});