03a4410272
主要工作是共享同一套“画布内核与通用 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>
597 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|