修复画布权威快照合并回归
Project CI / Repository checks (push) Successful in 1m19s
Project CI / Frontend tests (push) Failing after 2m10s
Project CI / Native shell tests (push) Failing after 3m27s
Project CI / Backend tests (push) Successful in 3m53s

统一生成完成态与本地布局的生命周期合并
保留同项目权威刷新中的有效画布选择
同步生成集成夹具与真实后端完成契约
补齐画布持久化回归测试和项目记忆
This commit is contained in:
2026-08-03 15:20:35 +08:00
parent cf0864c956
commit 53f06055a4
6 changed files with 192 additions and 55 deletions
@@ -428,6 +428,24 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
],
layers: [
...projectLayers,
{
itemType: 'generation-dialog',
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
dialog: {
id: canvasCompletion.dialogId,
mode: dialogMode,
prompt,
status: 'idle',
composerOpen: false,
generatedLayerId: layerId,
placeholder,
imageModel: model,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
},
},
{
layerId,
resourceId,
@@ -450,24 +468,6 @@ describe('ImageCanvasEditorView generation integration', () => {
assetKind: input.assetKind ?? undefined,
generationInputs: input.generationInputs,
},
{
itemType: 'generation-dialog',
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
dialog: {
id: canvasCompletion.dialogId,
mode: dialogMode,
prompt,
status: 'idle',
composerOpen: true,
generatedLayerId: layerId,
placeholder,
imageModel: model,
aspectRatio: input.aspectRatio,
imageSize: input.imageSize,
},
},
...projectLayers,
],
updatedAt: '2026-06-19T00:00:00.000Z',
},
@@ -887,10 +887,7 @@ describe('ImageCanvasEditorView generation integration', () => {
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
expect(generatedLayer).toBeTruthy();
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(
Number.isFinite(
Number.parseFloat((generatedLayer as HTMLElement).style.top),
@@ -978,10 +975,7 @@ describe('ImageCanvasEditorView generation integration', () => {
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
expect(
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
@@ -1522,7 +1516,28 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy();
});
expect(screen.getByText('规范')).toBeTruthy();
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
expectedRevision: 1,
layers: expect.arrayContaining([
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
mode: 'spec',
status: 'idle',
generatedLayerId: 'layer-editor-spec-role-1',
specValues: expect.objectContaining({
playSetting: '平台跳跃玩法',
artStyle: '低多边形卡通',
bodyRatio: '4',
characterView: '左向三分之二侧身站姿',
}),
}),
}),
]),
}),
);
});
it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
@@ -2094,10 +2109,11 @@ describe('ImageCanvasEditorView generation integration', () => {
const generatedImage = await screen.findByAltText(/画布图片:生成图片/u);
const generatedLayerButton = generatedImage.closest('button')!;
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
fireEvent.click(generatedLayerButton);
expect(generatedLayerButton.className).toContain(
'image-canvas-editor__layer--selected',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
button: 0,
@@ -3652,7 +3668,7 @@ describe('ImageCanvasEditorView generation integration', () => {
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
const metadataCornerButton = screen.getAllByRole('button', {
name: /查看生成图片 .*图片信息/,
@@ -1152,6 +1152,8 @@ export function ImageCanvasEditorView({
viewportRef,
canvasGenerationDialogsRef,
canvasBackgroundColorRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[],
);
@@ -1161,6 +1163,8 @@ export function ImageCanvasEditorView({
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter: (value: number) => {
layerCounterRef.current = value;
@@ -1170,6 +1174,8 @@ export function ImageCanvasEditorView({
}),
[
applyCanvasBackgroundColor,
setSelectedLayerId,
setSelectedLayerIds,
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayers,
@@ -108,6 +108,27 @@ function createCompletedEditorProjectSnapshot(
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '后端完成生成器',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-generated',
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
},
},
},
{
layerId: 'layer-generated',
resourceId: 'resource-generated',
@@ -121,27 +142,6 @@ function createCompletedEditorProjectSnapshot(
zIndex: 1,
sourceType: 'generated',
},
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:generation-dialog-1',
resourceId: 'generation-dialog:generation-dialog-1',
dialog: {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '后端完成生成器',
status: 'idle',
composerOpen: true,
generatedLayerId: 'layer-generated',
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
},
},
},
],
resources: [
{
@@ -314,7 +314,7 @@ it('merges pending geometry and dialog edits while retaining backend additions a
dialog: expect.objectContaining({
prompt: '请求在途期间的新提示词',
status: 'idle',
composerOpen: true,
composerOpen: false,
generatedLayerId: 'layer-generated-by-backend',
placeholder: expect.objectContaining({ x: 88, y: 99 }),
}),
@@ -359,11 +359,14 @@ function ProjectPersistenceHarness({
const [projectTitle, setProjectTitle] = useState('');
const [projectRenameValue, setProjectRenameValue] = useState('');
const [flushCompleted, setFlushCompleted] = useState(false);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const canvasGenerationDialogsRef = useRef(generationDialogs);
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
const selectedLayerRef = useRef<string | null>(null);
const selectedLayerRef = useRef<string | null>(selectedLayerId);
const selectedLayerIdsRef = useRef<string[]>(selectedLayerIds);
const layerCounterRef = useRef(0);
const openEditorLoginModalRef = useRef(vi.fn());
@@ -371,8 +374,11 @@ function ProjectPersistenceHarness({
viewportRef.current = viewport;
canvasGenerationDialogsRef.current = generationDialogs;
canvasBackgroundColorRef.current = canvasBackgroundColor;
selectedLayerRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
const selectSingleLayer = useCallback((layerId: string | null) => {
selectedLayerRef.current = layerId;
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
}, []);
const setLayerCounter = useCallback((value: number) => {
layerCounterRef.current = value;
@@ -391,6 +397,8 @@ function ProjectPersistenceHarness({
viewportRef,
canvasGenerationDialogsRef,
canvasBackgroundColorRef,
selectedLayerIdRef: selectedLayerRef,
selectedLayerIdsRef,
}),
[],
);
@@ -400,6 +408,8 @@ function ProjectPersistenceHarness({
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs: setGenerationDialogs,
@@ -439,6 +449,9 @@ function ProjectPersistenceHarness({
.join(',')}
</span>
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
<span data-testid="selected-ids">
{selectedLayerIdsRef.current.join(',') || '-'}
</span>
<span data-testid="counter">{layerCounterRef.current}</span>
<span data-testid="viewport">
{viewport.x},{viewport.y},{viewport.scale}
@@ -651,6 +664,28 @@ function ProjectPersistenceHarness({
>
apply completed project
</button>
<button
type="button"
onClick={() => {
setSelectedLayerId('layer-generated');
setSelectedLayerIds([
'layer-generated',
'generation-dialog:generation-dialog-1',
]);
}}
>
select completed objects
</button>
<button
type="button"
onClick={() => {
persistence.applyProjectSnapshot(
createCompletedEditorProjectSnapshot(2),
);
}}
>
reapply completed project
</button>
<button
type="button"
onClick={() => {
@@ -1183,6 +1218,45 @@ describe('useImageCanvasProjectPersistence', () => {
);
});
it('does not steal an empty selection when a same-project completion snapshot arrives', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
expect(screen.getByTestId('selected').textContent).toBe('-');
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated:resource-generated',
);
expect(screen.getByTestId('selected').textContent).toBe('-');
});
it('preserves valid layer and generation selections across a same-project snapshot', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
act(() => {
screen.getByRole('button', { name: 'select completed objects' }).click();
});
expect(screen.getByTestId('selected-ids').textContent).toBe(
'layer-generated,generation-dialog:generation-dialog-1',
);
act(() => {
screen.getByRole('button', { name: 'reapply completed project' }).click();
});
expect(screen.getByTestId('selected').textContent).toBe('layer-generated');
expect(screen.getByTestId('selected-ids').textContent).toBe(
'layer-generated,generation-dialog:generation-dialog-1',
);
});
it('rejects an asynchronous project callback after authority is revoked', async () => {
const { rerender } = render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
@@ -43,6 +43,10 @@ import {
type ProjectCoverSnapshotViewportSize,
} from './ImageCanvasProjectCoverSnapshotModel';
import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer';
import {
firstSelectedLayerId,
normalizeCanvasSelectionIds,
} from './ImageCanvasSelectionModel';
type ProjectResourceOptions = {
onCreated?: (resourceId: string) => void;
@@ -127,6 +131,7 @@ function mergePendingCanvasLayerLayout(
generationFinishedAt: authoritativeDialog.generationFinishedAt,
characterAnimationResult:
authoritativeDialog.characterAnimationResult,
composerOpen: authoritativeDialog.composerOpen,
},
};
}
@@ -195,6 +200,8 @@ type ImageCanvasProjectPersistenceRefs = {
viewportRef: RefObject<CanvasViewport>;
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
canvasBackgroundColorRef: RefObject<string>;
selectedLayerIdRef: RefObject<string | null>;
selectedLayerIdsRef: RefObject<string[]>;
};
type ImageCanvasProjectPersistenceSetters = {
@@ -202,6 +209,8 @@ type ImageCanvasProjectPersistenceSetters = {
setProjectRenameValue: (title: string) => void;
setViewport: (viewport: CanvasViewport) => void;
setLayers: (layers: CanvasLayer[]) => void;
setSelectedLayerId: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
selectSingleLayer: (layerId: string | null) => void;
setLayerCounter: (value: number) => void;
restoreCanvasGenerationDialogs: (
@@ -541,6 +550,8 @@ export function useImageCanvasProjectPersistence({
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter,
restoreCanvasGenerationDialogs,
@@ -1183,6 +1194,8 @@ export function useImageCanvasProjectPersistence({
: null;
const previousAuthoritativeItemIds =
authoritativeLayoutItemIdsRef.current;
const applyingToCurrentProject =
projectIdRef.current === project.projectId;
clearPendingProjectLayoutSave();
skipNextProjectLayoutSaveRef.current = true;
if (projectIdRef.current !== project.projectId) {
@@ -1268,7 +1281,30 @@ export function useImageCanvasProjectPersistence({
setLayerCounter(hydratedLayers.length);
refs.layersRef.current = hydratedLayers;
setLayers(hydratedLayers);
selectSingleLayer(hydratedLayers[0]?.id ?? null);
if (applyingToCurrentProject) {
const currentSelectionIds = refs.selectedLayerIdsRef.current.length
? refs.selectedLayerIdsRef.current
: refs.selectedLayerIdRef.current
? [refs.selectedLayerIdRef.current]
: [];
const nextSelectionIds = normalizeCanvasSelectionIds({
selectionIds: currentSelectionIds,
layers: hydratedLayers,
generationDialogs,
});
const nextSelectedLayerId = firstSelectedLayerId(nextSelectionIds);
refs.selectedLayerIdRef.current = nextSelectedLayerId;
refs.selectedLayerIdsRef.current = nextSelectionIds;
setSelectedLayerId(nextSelectedLayerId);
setSelectedLayerIds(nextSelectionIds);
} else {
const nextSelectedLayerId = hydratedLayers[0]?.id ?? null;
refs.selectedLayerIdRef.current = nextSelectedLayerId;
refs.selectedLayerIdsRef.current = nextSelectedLayerId
? [nextSelectedLayerId]
: [];
selectSingleLayer(nextSelectedLayerId);
}
refs.canvasGenerationDialogsRef.current = generationDialogs;
restoreCanvasGenerationDialogs(generationDialogs);
applyCanvasBackgroundColor(
@@ -1309,6 +1345,8 @@ export function useImageCanvasProjectPersistence({
selectSingleLayer,
setLayerCounter,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
setProjectRenameValue,
setProjectTitle,
setViewport,