Files
Genarrative/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx
T
k88936 7288c6f641
Project CI / Repository checks (push) Successful in 2m59s
Project CI / Frontend tests (push) Successful in 3m29s
Project CI / Backend tests (push) Successful in 8m37s
Project CI / Native shell tests (push) Successful in 20m55s
Fix/修复无限画布图标文字模糊 (#285)
closes #279
closes #284

实现: 从2(暂定)倍放大绘制的画布scale 0.5* 真正的scale

before:
![shotmd-1788579338.jpg](/attachments/9d44ec5e-454c-449b-86bc-b2131fbbc8cc)
![shotmd-1788578910.jpg](/attachments/c9da61a2-b467-43eb-a5de-e5e4d5cb8f70)

after:
![shotmd-1788580930.jpg](/attachments/f1f06e8e-90bf-45e2-a30b-a63ccc308c19)
![shotmd-1788583400.jpg](/attachments/c48ca741-e470-42b3-8030-064e0579f63a)
svg看起来stroke窄了一点点, 可以接受

Reviewed-on: #285
2026-09-09 16:32:43 +08:00

4311 lines
145 KiB
TypeScript

/** @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type {
ImageCanvasDraft,
ImageCanvasDraftCanvas,
ImageCanvasGenerationPort,
ImageCanvasGenerationProgress,
ImageCanvasGenerationServiceIdentityConfirmation,
ImageCanvasHostScope,
ImageCanvasProjectPort,
} from '@genarrative/image-canvas-core';
import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
canonicalAssetBaseName,
isValidAssetCanvasName,
} from '../src/features/asset-canvas/assetCanvasNaming';
import {
AssetCanvasSurface,
generationAspectRatioForOriginalImage,
type RenderAssetCanvasImage,
resolveQuickEditPanelPosition,
shouldApplyAssetCanvasDraftCandidate,
} from '../src/features/asset-canvas/AssetCanvasSurface';
import {
createTauriImageCanvasHostAdapter,
type LocalAssetCommittedEvent,
type TauriImageCanvasHostAdapter,
} from '../src/features/asset-canvas/tauriImageCanvasHostAdapter';
import {
clearStoredAuthAccessToken,
setStoredAuthAccessToken,
} from '../src/services/clientApi';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
resetPlatformSessionStateForTests,
} from '../src/services/platformSession';
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(
type: string,
init: MouseEventInit & { pointerId?: number } = {},
) {
super(type, init);
this.pointerId = init.pointerId ?? 1;
}
}
const scope: ImageCanvasHostScope = {
projectId: 'project-one',
draftId: '11111111-1111-4111-8111-111111111111',
intent: 'create',
sourceAssetId: null,
};
function emptyCanvas(): ImageCanvasDraftCanvas {
return {
viewport: { x: 0, y: 0, scale: 0.5 },
backgroundColor: '#f4f4f5',
layers: [],
selectedLayerIds: [],
primarySelectedLayerId: null,
};
}
function keyboardCanvas(): ImageCanvasDraftCanvas {
return {
...emptyCanvas(),
layers: ['第一层', '第二层'].map((title, index) => ({
layerId: `keyboard-layer-${index + 1}`,
resourceId: `draft-media:keyboard-${index + 1}`,
title,
mediaRef: {
kind: 'draft-media' as const,
mediaId: `keyboard-${index + 1}`,
mediaType: 'image/png' as const,
sha256: String(index + 1).repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
x: index * 60,
y: 0,
width: 40,
height: 30,
originalWidth: 40,
originalHeight: 30,
zIndex: index,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
})),
};
}
function draftFixture(
draftScope: ImageCanvasHostScope = scope,
canvas: ImageCanvasDraftCanvas = emptyCanvas(),
): ImageCanvasDraft {
return {
schemaVersion: 'game-creator-asset-canvas-draft.v1',
draftId: draftScope.draftId,
projectId: draftScope.projectId,
intent: draftScope.intent,
sourceAssetId: draftScope.sourceAssetId,
sourceResourceId:
draftScope.intent === 'refine' ? 'local-asset:source-asset' : null,
revision: 0,
status: 'editing',
canvas,
generations: [],
pendingCommit: null,
lastCommit: null,
createdAt: 1,
updatedAt: 1,
};
}
function manifestFixture(projectId: string): GameCreationAppManifest {
return {
schemaVersion: 'game-creation-app-manifest.v1',
projectId,
name: '素材画布测试',
assets: [
{
id: 'canvas-commit',
kind: 'illustration',
mediaType: 'image/png',
localPath: 'assets/canvas/result.png',
source: {
kind: 'canvas',
resourceId: 'local-asset:canvas-commit',
referenceResourceIds: [],
},
},
],
} as unknown as GameCreationAppManifest;
}
type Deferred<T> = {
promise: Promise<T>;
resolve(value: T): void;
};
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
return {
promise: new Promise<T>((done) => {
resolve = done;
}),
resolve,
};
}
function memoryHost(input?: {
initialDraft?: ImageCanvasDraft | null;
recoverGate?: Deferred<unknown>;
recoverImagesGate?: Deferred<unknown>;
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
candidateAcknowledgementGate?: Deferred<void>;
initialUnacknowledgedCandidateLayerIds?: string[];
generationFailure?: { code: string; message: string };
generationFailureNotStarted?: boolean;
updateFailure?: { code: string; message: string };
recoverFailure?: { code: string; message: string };
commitFailure?: { code: string; message: string };
discardFailure?: { code: string; message: string };
recoveryEvent?: LocalAssetCommittedEvent;
serviceIdentityConfirmation?: ImageCanvasGenerationServiceIdentityConfirmation;
}) {
let draft = input?.initialDraft ?? null;
let hostRevision = 0;
let eventListener: ((event: LocalAssetCommittedEvent) => void) | null = null;
let pendingServiceIdentityConfirmation =
input?.serviceIdentityConfirmation ?? null;
let serviceIdentityWasConfirmed = false;
let recoveryProgressListener:
| ((progress: ImageCanvasGenerationProgress) => void)
| undefined;
const updates: ImageCanvasDraftCanvas[] = [];
const persistenceOperations: Array<
| { kind: 'update'; canvas: ImageCanvasDraftCanvas }
| { kind: 'acknowledge'; layerIds: string[] }
> = [];
const unacknowledgedCandidateLayerIds = new Set(
input?.initialUnacknowledgedCandidateLayerIds ?? [],
);
const commits: Array<{
commitId: string;
idempotencyKey: string;
assetName: string;
assetKind: string;
}> = [];
const selectedCandidateCommits: Array<{
sourceLayerId: string;
commitId: string;
assetName: string;
assetKind: string;
}> = [];
const generationCalls: Array<{
intentId: string;
generationId: string;
idempotencyKey: string;
commitId: string;
commitIdempotencyKey: string;
referenceResourceIds: string[];
sourceLayerId: string | null;
placeholder: {
x: number;
y: number;
width: number;
height: number;
} | null;
}> = [];
const revokedSubscriptions = vi.fn();
const nativeImportCalls = vi.fn();
const manifest = manifestFixture(draft?.projectId ?? scope.projectId);
const loadDraft = vi.fn(async () => ({
status: 'ok' as const,
value: draft,
}));
const recover = vi.fn(async () => {
await input?.recoverGate?.promise;
if (input?.recoverFailure) {
return {
status: 'failed' as const,
code: input.recoverFailure.code,
message: input.recoverFailure.message,
};
}
if (input?.recoveryEvent) eventListener?.(input.recoveryEvent);
return {
status: 'ok' as const,
value: { projectRevision: hostRevision, manifest },
};
});
const discardDraft = vi.fn(async () => {
if (!draft) throw new Error('draft missing');
if (input?.discardFailure) {
return {
status: 'failed' as const,
code: input.discardFailure.code,
message: input.discardFailure.message,
};
}
draft = { ...draft, revision: draft.revision + 1, status: 'cancelled' };
return { status: 'ok' as const, value: draft };
});
const recoverImages = vi.fn(
async (
recoverInput: Parameters<ImageCanvasGenerationPort['recoverImages']>[0],
) => {
recoveryProgressListener = recoverInput.onProgress;
await input?.recoverImagesGate?.promise;
return {
status: 'ok' as const,
value: {
resumedGenerationIds: serviceIdentityWasConfirmed
? ['recovered-generation']
: [],
serviceIdentityConfirmations: pendingServiceIdentityConfirmation
? [pendingServiceIdentityConfirmation]
: [],
},
};
},
);
const updateDraft = vi.fn(
async (update: Parameters<ImageCanvasProjectPort['updateDraft']>[0]) => {
if (input?.updateFailure) {
return {
status: 'failed' as const,
code: input.updateFailure.code,
message: input.updateFailure.message,
};
}
if (!draft || update.expectedDraftRevision !== draft.revision) {
return {
status: 'conflict' as const,
conflictKind: 'draft-revision',
draft,
hostRevision: String(hostRevision),
};
}
const protectedLayers = [...update.canvas.layers];
for (const layerId of unacknowledgedCandidateLayerIds) {
const candidateLayer = draft.canvas.layers.find(
(layer) => layer.layerId === layerId,
);
if (
candidateLayer &&
!protectedLayers.some((layer) => layer.layerId === layerId)
) {
protectedLayers.push(candidateLayer);
}
}
const protectedCanvas = { ...update.canvas, layers: protectedLayers };
updates.push(protectedCanvas);
persistenceOperations.push({ kind: 'update', canvas: protectedCanvas });
draft = {
...draft,
revision: draft.revision + 1,
status: update.status,
canvas: protectedCanvas,
generations: update.generations,
updatedAt: draft.updatedAt + 1,
};
return { status: 'ok' as const, value: draft };
},
);
const acknowledgeCandidateLayers = vi.fn(
async (
acknowledgement: Parameters<
ImageCanvasProjectPort['acknowledgeCandidateLayers']
>[0],
) => {
await input?.candidateAcknowledgementGate?.promise;
if (!draft) throw new Error('draft missing');
persistenceOperations.push({
kind: 'acknowledge',
layerIds: [...acknowledgement.layerIds],
});
for (const layerId of acknowledgement.layerIds) {
if (draft.canvas.layers.some((layer) => layer.layerId === layerId)) {
unacknowledgedCandidateLayerIds.delete(layerId);
}
}
return { status: 'ok' as const, value: draft };
},
);
const settleGenerationFailure = vi.fn(
async (
failureInput: Parameters<
ImageCanvasGenerationPort['settleGenerationFailure']
>[0],
) => {
if (!draft || failureInput.expectedDraftRevision !== draft.revision) {
return {
status: 'conflict' as const,
conflictKind: 'draft-revision' as const,
draft,
hostRevision: String(hostRevision),
};
}
if (input?.generationFailureNotStarted) {
draft = {
...draft,
revision: draft.revision + 1,
status: 'editing',
generations: draft.generations.filter(
(record) => record.generationId !== failureInput.generationId,
),
updatedAt: draft.updatedAt + 1,
};
return {
status: 'ok' as const,
value: { disposition: 'not-started' as const, draft },
};
}
const phase = failureInput.reconciliationRequired
? ('reconciliation-required' as const)
: ('failed' as const);
const nextUpdatedAt = draft.updatedAt + 1;
draft = {
...draft,
revision: draft.revision + 1,
status: 'editing',
generations: draft.generations.map((record) =>
record.generationId === failureInput.generationId
? {
...record,
phase,
errorCode: failureInput.errorCode,
updatedAt: nextUpdatedAt,
}
: record,
),
updatedAt: draft.updatedAt + 1,
};
return {
status: 'ok' as const,
value: { disposition: phase, draft },
};
},
);
const confirmGenerationServiceIdentity = vi.fn(
async ({
confirmation,
}: {
confirmation: ImageCanvasGenerationServiceIdentityConfirmation;
}) => {
pendingServiceIdentityConfirmation = null;
serviceIdentityWasConfirmed = true;
return {
status: 'ok' as const,
value: {
generationId: confirmation.generationId,
operationId: confirmation.operationId,
operationState: confirmation.operationState,
serviceOrigin: confirmation.serviceOrigin,
identityScheme: 'service-origin-v1' as const,
},
};
},
);
const host: TauriImageCanvasHostAdapter = {
kind: 'tauri',
projectPath: '/fixture/project',
expectedProjectId: draft?.projectId ?? scope.projectId,
capabilities: {
account: false,
wallet: false,
cloudAssetLibrary: false,
localProject: true,
externalEditorGeneration: true,
advancedBackgroundRemoval: false,
},
project: {
loadDraft,
async createDraft(nextScope) {
draft = draftFixture(nextScope);
return { status: 'ok', value: draft };
},
updateDraft,
acknowledgeCandidateLayers,
discardDraft,
},
asset: {
async importLocalImages(importInput) {
nativeImportCalls(importInput);
if (!draft || importInput.expectedDraftRevision !== draft.revision) {
return {
status: 'conflict' as const,
conflictKind: 'draft-revision' as const,
draft,
hostRevision: String(hostRevision),
};
}
const layerId = `native-import-${draft.revision}`;
const importedLayer = {
layerId,
resourceId: `draft-media:${layerId}`,
title: 'native-import.png',
mediaRef: {
kind: 'draft-media' as const,
mediaId: layerId,
mediaType: 'image/png' as const,
sha256: 'c'.repeat(64),
byteLength: 4,
pixelWidth: 320,
pixelHeight: 180,
},
x: 160,
y: 120,
width: 320,
height: 180,
originalWidth: 320,
originalHeight: 180,
zIndex: draft.canvas.layers.length,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
};
draft = {
...draft,
revision: draft.revision + 1,
canvas: {
...draft.canvas,
layers: [...draft.canvas.layers, importedLayer],
selectedLayerIds: [layerId],
primarySelectedLayerId: layerId,
},
};
return {
status: 'ok' as const,
value: {
status: 'imported' as const,
draft,
importedLayerIds: [layerId],
},
};
},
async importImages(importInput) {
await input?.importGate?.promise;
return {
status: 'ok',
value: importInput.images.map((image, index) => ({
mediaRef: {
kind: 'draft-media' as const,
mediaId: `media-${index}`,
mediaType: image.mediaType,
sha256: 'a'.repeat(64),
byteLength: image.bytes.length,
pixelWidth: 64,
pixelHeight: 48,
},
previewUrl: `blob:import-${index}`,
resourceId: `draft-media:media-${index}`,
})),
};
},
async exportImage() {
return {
status: 'failed',
code: 'commit-required',
message: 'commit required',
};
},
},
generation: {
settleGenerationFailure,
async archiveFailedGeneration(archiveInput) {
if (!draft || archiveInput.expectedDraftRevision !== draft.revision) {
return {
status: 'conflict' as const,
conflictKind: 'draft-revision' as const,
draft,
hostRevision: String(hostRevision),
};
}
draft = {
...draft,
revision: draft.revision + 1,
generations: draft.generations.filter(
(generation) =>
generation.generationId !== archiveInput.generationId,
),
};
return { status: 'ok' as const, value: draft };
},
async generateImage(generationInput) {
generationCalls.push({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
idempotencyKey: generationInput.idempotencyKey,
commitId: generationInput.commitId,
commitIdempotencyKey: generationInput.commitIdempotencyKey,
referenceResourceIds: generationInput.referenceResourceIds,
sourceLayerId: generationInput.sourceLayerId,
placeholder: generationInput.placeholder,
});
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'generation-accepted',
progress: 10,
errorCode: null,
});
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'generation-running',
progress: 35,
errorCode: null,
});
await input?.generationGate?.promise;
if (input?.generationFailure) {
return {
status: 'failed' as const,
code: input.generationFailure.code,
message: input.generationFailure.message,
};
}
if (!draft) throw new Error('draft missing');
const candidateLayer = {
layerId: `generated-layer-${generationInput.generationId}`,
resourceId: `draft-media:generated-${generationInput.generationId}`,
title: `${generationInput.assetName} 候选图`,
mediaRef: {
kind: 'draft-media' as const,
mediaId: `generated-${generationInput.generationId}`,
mediaType: 'image/png' as const,
sha256: 'b'.repeat(64),
byteLength: 4,
pixelWidth: 64,
pixelHeight: 64,
},
x: 100,
y: 100,
width: 64,
height: 64,
originalWidth: 64,
originalHeight: 64,
zIndex: draft.canvas.layers.length,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
};
unacknowledgedCandidateLayerIds.add(candidateLayer.layerId);
draft = {
...draft,
revision: draft.revision + 1,
status: 'editing',
canvas: {
...draft.canvas,
layers: [...draft.canvas.layers, candidateLayer],
selectedLayerIds: [candidateLayer.layerId],
primarySelectedLayerId: candidateLayer.layerId,
},
};
const existingGeneration = draft.generations.find(
(generation) =>
generation.generationId === generationInput.generationId,
);
const generation = {
generationId: generationInput.generationId,
intentId: generationInput.intentId,
phase: 'candidate-ready' as const,
referenceResourceIds: generationInput.referenceResourceIds,
outputAssetId: null,
sourceLayerId: generationInput.sourceLayerId,
placeholder:
existingGeneration?.placeholder ?? generationInput.placeholder,
errorCode: null,
createdAt: existingGeneration?.createdAt ?? 1,
updatedAt: 2,
};
draft = {
...draft,
generations: [
...draft.generations.filter(
(record) => record.generationId !== generationInput.generationId,
),
generation,
],
};
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'candidate-ready',
progress: 100,
errorCode: null,
});
return {
status: 'ok' as const,
value: {
generation,
images: [
{
mediaRef: candidateLayer.mediaRef,
previewUrl: 'blob:generated',
resourceId: candidateLayer.resourceId,
},
],
draft,
},
};
},
recoverImages,
async removeBackground() {
return {
status: 'unsupported-capability' as const,
capability: 'advancedBackgroundRemoval' as const,
message: '当前阶段不接真实去背景',
};
},
},
confirmGenerationServiceIdentity,
completion: {
async commitSelectedCandidate(commitInput) {
selectedCandidateCommits.push({
sourceLayerId: commitInput.sourceLayerId,
commitId: commitInput.commitId,
assetName: commitInput.name,
assetKind: commitInput.assetKind,
});
await input?.commitGate?.promise;
if (input?.commitFailure) {
return {
status: 'failed' as const,
code: input.commitFailure.code,
message: input.commitFailure.message,
};
}
hostRevision += 1;
if (!draft) throw new Error('draft missing');
const selectedLayer = draft.canvas.layers.find(
(layer) => layer.layerId === commitInput.sourceLayerId,
);
if (!selectedLayer || selectedLayer.mediaRef.kind !== 'draft-media') {
throw new Error('selected candidate missing');
}
draft = {
...draft,
revision: draft.revision + 1,
status: 'editing',
lastCommit: {
commitId: commitInput.commitId,
idempotencyKey: commitInput.idempotencyKey,
assetId: 'canvas-commit',
eventId: 'event-candidate-one',
committedProjectRevision: hostRevision,
sourceLayerId: commitInput.sourceLayerId,
mediaSha256: selectedLayer.mediaRef.sha256,
},
};
return {
status: 'ok' as const,
value: {
resourceId: 'local-asset:canvas-commit',
assetId: 'canvas-commit',
projectId: scope.projectId,
commitId: commitInput.commitId,
committedProjectRevision: hostRevision,
draftRevision: draft.revision,
hostRevision: String(hostRevision),
commitStatus: 'committed' as const,
manifest,
eventId: 'event-candidate-one',
},
};
},
async commitImage(commitInput) {
commits.push({
commitId: commitInput.commitId,
idempotencyKey: commitInput.idempotencyKey,
assetName: commitInput.name,
assetKind: commitInput.assetKind,
});
await input?.commitGate?.promise;
if (input?.commitFailure) {
return {
status: 'failed' as const,
code: input.commitFailure.code,
message: input.commitFailure.message,
};
}
hostRevision += 1;
if (!draft) throw new Error('draft missing');
draft = { ...draft, revision: draft.revision + 1, status: 'committed' };
return {
status: 'ok',
value: {
resourceId: 'local-asset:canvas-commit',
draftRevision: draft.revision,
hostRevision: String(hostRevision),
commitStatus: 'committed',
manifest,
eventId: 'event-one',
},
};
},
},
async readMediaPreview() {
return { status: 'ok', value: { previewUrl: 'blob:restored' } };
},
recover,
async subscribeCommitted(callback) {
eventListener = callback;
return () => {
eventListener = null;
revokedSubscriptions();
};
},
};
return {
host,
commits,
selectedCandidateCommits,
generationCalls,
updates,
persistenceOperations,
revokedSubscriptions,
nativeImportCalls,
loadDraft,
recover,
recoverImages,
settleGenerationFailure,
updateDraft,
acknowledgeCandidateLayers,
confirmGenerationServiceIdentity,
discardDraft,
getDraft: () => draft,
setDraft(nextDraft: ImageCanvasDraft) {
draft = nextDraft;
},
emitRecoveryProgress(progress: ImageCanvasGenerationProgress) {
recoveryProgressListener?.(progress);
},
emit(event: LocalAssetCommittedEvent) {
eventListener?.(event);
},
};
}
const renderImage: RenderAssetCanvasImage = vi.fn(async () =>
Uint8Array.from([137, 80, 78, 71]),
);
function renderSurface(
host: TauriImageCanvasHostAdapter,
canvasScope: ImageCanvasHostScope = scope,
onCommitted = vi.fn(),
onSaveAttempt = vi.fn(),
onCancel = vi.fn(),
initialAsset?: { name: string; kind: string },
initialOpenQuickEdit = false,
) {
return {
onCommitted,
onSaveAttempt,
onCancel,
...render(
<div style={{ width: 1280, height: 800 }}>
<AssetCanvasSurface
host={host}
scope={canvasScope}
sessionId="22222222-2222-4222-8222-222222222222"
expectedHostRevision="0"
initialAssetName={initialAsset?.name}
initialAssetKind={initialAsset?.kind}
initialOpenQuickEdit={initialOpenQuickEdit}
onCancel={onCancel}
onCommitted={onCommitted}
onSaveAttempt={onSaveAttempt}
renderImage={renderImage}
/>
</div>,
),
};
}
function expectAssetCanvasBackgroundLocked(
hiddenFromAccessibilityTree = false,
) {
const toolbarShell = document.querySelector(
'.asset-canvas-surface__toolbar-shell',
);
const viewport = document.querySelector('.asset-canvas-surface__viewport');
const viewportTools = document.querySelector(
'.asset-canvas-surface__viewport-tools',
);
const status = document.querySelector('.asset-canvas-surface__status');
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
}) as HTMLButtonElement;
const commit = screen.getByRole('button', {
name: /保存到项目|设为最终图/,
hidden: true,
}) as HTMLButtonElement;
expect(toolbarShell?.hasAttribute('inert')).toBe(true);
expect(viewport?.hasAttribute('inert')).toBe(true);
expect(viewportTools?.hasAttribute('inert')).toBe(true);
if (hiddenFromAccessibilityTree) {
expect(toolbarShell?.getAttribute('aria-hidden')).toBe('true');
expect(viewport?.getAttribute('aria-hidden')).toBe('true');
expect(viewportTools?.getAttribute('aria-hidden')).toBe('true');
expect(status?.hasAttribute('inert')).toBe(true);
expect(status?.getAttribute('aria-hidden')).toBe('true');
}
expect(generate.disabled).toBe(true);
expect(commit.disabled).toBe(true);
}
function expectLockedBackgroundCannotCallPaidPorts(
memory: ReturnType<typeof memoryHost>,
) {
const generationCallCount = memory.generationCalls.length;
const commitCallCount = memory.commits.length;
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
}) as HTMLButtonElement;
const commit = screen.getByRole('button', {
name: '保存到项目',
hidden: true,
}) as HTMLButtonElement;
// 模拟宿主或测试绕过 disabled,确认 handler 自身仍有生命周期硬门禁。
generate.disabled = false;
commit.disabled = false;
fireEvent.click(generate);
fireEvent.click(commit);
generate.disabled = true;
commit.disabled = true;
expect(memory.generationCalls).toHaveLength(generationCallCount);
expect(memory.commits).toHaveLength(commitCallCount);
}
beforeEach(() => {
resetPlatformSessionStateForTests();
clearStoredAuthAccessToken();
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
});
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:fixture'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
resetPlatformSessionStateForTests();
clearStoredAuthAccessToken();
delete window.__TAURI__;
vi.restoreAllMocks();
});
describe('Tauri 素材创作无限画布独立 Surface', () => {
it('快速编辑卡位置始终钳制在 viewport 四边内并在底部翻转到上方', () => {
const common = {
canvasSize: { width: 320, height: 240 },
panelSize: { width: 220, height: 120 },
edgePadding: 12,
};
expect(
resolveQuickEditPanelPosition({
...common,
anchorX: 4,
belowTop: 180,
aboveTop: 20,
}),
).toEqual({ left: 122, top: 20 });
expect(
resolveQuickEditPanelPosition({
...common,
anchorX: 316,
belowTop: 40,
aboveTop: -100,
}),
).toEqual({ left: 198, top: 40 });
expect(
resolveQuickEditPanelPosition({
...common,
anchorX: 160,
belowTop: 200,
aboveTop: -100,
}),
).toEqual({ left: 160, top: 12 });
});
it('窄屏快速编辑卡使用 12px 四边留白,320×480 下输入区仍在 viewport 内', () => {
const css = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css',
),
'utf8',
);
expect(css).toContain('width: calc(100% - 24px)');
expect(css).toContain('max-height: min(20rem, calc(100% - 24px))');
expect(
resolveQuickEditPanelPosition({
anchorX: 160,
belowTop: 330,
aboveTop: 20,
canvasSize: { width: 320, height: 480 },
panelSize: { width: 296, height: 456 },
edgePadding: 12,
}),
).toEqual({ left: 160, top: 12 });
});
it('从精修文件名剥离历史提交后缀并保持后端合法名称', () => {
expect(
canonicalAssetBaseName(
'assets/canvas/direct-game-background--fb66de66-e50c-4c13-8fcc-8ed7b5b49aff--6faacb1d-8f56-4186-ad51-fa70988d4879.png',
),
).toBe('direct-game-background');
expect(canonicalAssetBaseName('assets/art.png ')).toBe('art');
expect(canonicalAssetBaseName('assets/CON.png')).toBe('画布素材');
expect(
canonicalAssetBaseName(
`assets/${'很'.repeat(90)}--fb66de66-e50c-4c13-8fcc-8ed7b5b49aff.png`,
),
).toHaveLength(80);
expect(isValidAssetCanvasName('direct-game-background')).toBe(true);
expect(isValidAssetCanvasName('name ')).toBe(false);
});
it('修改图片时将原图比例映射为最接近的受支持比例,异常尺寸回退 1:1', () => {
expect(generationAspectRatioForOriginalImage(1600, 900)).toBe('16:9');
expect(generationAspectRatioForOriginalImage(900, 1600)).toBe('9:16');
expect(generationAspectRatioForOriginalImage(1200, 800)).toBe('3:2');
expect(generationAspectRatioForOriginalImage(800, 1200)).toBe('2:3');
expect(generationAspectRatioForOriginalImage(0, 1200)).toBe('1:1');
expect(generationAspectRatioForOriginalImage(Number.NaN, 1200)).toBe('1:1');
});
it('直接消费共享 chrome,并暴露工具栏结构与原生按钮状态', async () => {
const memory = memoryHost();
renderSurface(memory.host);
await screen.findByText('画布可编辑');
const toolbar = screen.getByRole('toolbar', { name: '素材画布工具栏' });
expect(
toolbar.classList.contains('genarrative-image-canvas__toolbar'),
).toBe(true);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
).toHaveLength(3);
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
).toHaveLength(2);
const generate = screen.getByRole('button', { name: 'AI 生成图片' });
expect(
generate.classList.contains('genarrative-image-canvas__chrome-button'),
).toBe(true);
expect(generate.getAttribute('aria-expanded')).toBe('false');
expect(
(screen.getByRole('button', { name: '撤销' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '重做' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '删除' }) as HTMLButtonElement)
.disabled,
).toBe(true);
fireEvent.click(generate);
expect(generate.getAttribute('aria-expanded')).toBe('true');
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expect(
screen
.getByRole('button', { name: '继续确认' })
.classList.contains('genarrative-image-canvas__chrome-button'),
).toBe(true);
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
});
it('精修提交候选后定位当前最终图只跟随 lastCommit 的候选图层', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '入口原图快照',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'source-snapshot',
mediaType: 'image/png' as const,
sha256: 'a'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
x: 0,
y: 0,
};
const candidateLayer = {
...keyboardCanvas().layers[1],
layerId: 'candidate-layer',
resourceId: 'draft-media:candidate',
title: '已提交候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'candidate',
mediaType: 'image/png' as const,
sha256: 'b'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
x: 3000,
y: 2800,
};
const initialDraft = draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer, candidateLayer],
});
initialDraft.lastCommit = {
commitId: 'commit-candidate',
idempotencyKey: 'idempotency-candidate',
assetId: 'source-asset',
eventId: 'event-candidate',
committedProjectRevision: 1,
sourceLayerId: candidateLayer.layerId,
mediaSha256: candidateLayer.mediaRef.sha256,
};
const memory = memoryHost({ initialDraft });
renderSurface(memory.host, refineScope);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: '定位当前最终图' }));
expect(
screen
.getByRole('button', { name: '选择图层 已提交候选图' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '选择图层 入口原图快照' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(
document.querySelector<HTMLElement>('.genarrative-image-canvas__world')
?.style.transform,
).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 }));
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
});
it('精修顶栏只保留画布级动作,图片点击直接打开快速编辑', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '当前最终图',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
x: 3000,
y: 2800,
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
});
renderSurface(memory.host, refineScope);
await screen.findByText('画布可编辑');
const toolbar = screen.getByRole('toolbar', { name: '素材画布工具栏' });
expect(
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
).toHaveLength(2);
expect(screen.queryByRole('button', { name: 'AI 生成图片' })).toBeNull();
expect(screen.queryByRole('toolbar', { name: '图片编辑工具' })).toBeNull();
expect(screen.queryByRole('region', { name: '素材保存设置' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '定位当前最终图' }));
const layerButton = screen.getByRole('button', {
name: '选择图层 当前最终图',
});
expect(layerButton.getAttribute('aria-pressed')).toBe('true');
expect(
document.querySelector<HTMLElement>('.genarrative-image-canvas__world')
?.style.transform,
).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 }));
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
fireEvent.click(layerButton);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
expect(
within(quickEdit).getByRole('button', { name: '删除' }),
).toBeTruthy();
expect(
within(quickEdit).getByRole('button', { name: '修改' }),
).toBeTruthy();
expect(
within(quickEdit).queryByRole('button', { name: '设为最终图' }),
).toBeNull();
expect(screen.queryByRole('toolbar', { name: '图片编辑工具' })).toBeNull();
});
it('拖动图片不误开快速编辑,直接点击后修改立即提交生成', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const { onCommitted } = renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.pointerDown(first, {
pointerId: 1,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(window, {
pointerId: 1,
clientX: 40,
clientY: 30,
});
fireEvent.pointerUp(window, {
pointerId: 1,
clientX: 40,
clientY: 30,
});
fireEvent.click(first);
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull();
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
expect(screen.queryByRole('toolbar', { name: '图片编辑工具' })).toBeNull();
expect(
within(quickEdit).getByRole('button', { name: '删除' }),
).toBeTruthy();
expect(
within(quickEdit).getByRole('button', { name: '修改' }),
).toBeTruthy();
expect(
within(quickEdit).queryByRole('button', { name: '设为最终图' }),
).toBeNull();
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '让素材更明亮' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
expect(screen.queryByRole('dialog', { name: '确认图片生成' })).toBeNull();
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
expect(memory.generationCalls[0]?.sourceLayerId).toBe('keyboard-layer-1');
expect(onCommitted).not.toHaveBeenCalled();
expect(memory.commits).toHaveLength(0);
expect(memory.selectedCandidateCommits).toHaveLength(0);
expect(
await screen.findByRole('button', {
name: /选择图层 .*候选图/,
}),
).toBeTruthy();
});
it('编辑资源入口在草稿恢复后默认打开快速编辑并保留精修源资源引用', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
draftId: '33333333-3333-4333-8333-333333333333',
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
resourceId: 'local-asset:source-asset',
title: '源资源',
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
});
renderSurface(
memory.host,
refineScope,
undefined,
undefined,
undefined,
undefined,
true,
);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
expect(within(quickEdit).getByLabelText('图片提示词')).not.toBeNull();
expect(
(within(quickEdit).getByLabelText('图片比例') as HTMLSelectElement).value,
).toBe('3:2');
expect(within(quickEdit).getByText('参考资源:源资源')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: 'AI 图片生成' })).toBeNull();
fireEvent.click(
within(quickEdit).getByRole('button', { name: '关闭快速编辑' }),
);
await waitFor(() =>
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull(),
);
});
it('服务身份确认关闭后仍会打开默认快速编辑', async () => {
const user = userEvent.setup();
const refineScope: ImageCanvasHostScope = {
...scope,
draftId: '44444444-4444-4444-8444-444444444444',
intent: 'refine',
sourceAssetId: 'source-asset',
};
const confirmation: ImageCanvasGenerationServiceIdentityConfirmation = {
generationId: '55555555-5555-4555-8555-555555555555',
operationId: 'existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-value-that-is-long-enough-for-the-contract',
expiresAt: Date.now() + 60_000,
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, keyboardCanvas()),
serviceIdentityConfirmation: confirmation,
});
renderSurface(
memory.host,
refineScope,
undefined,
undefined,
undefined,
undefined,
true,
);
await screen.findByRole('dialog', { name: '确认旧生成任务服务' });
await user.click(
screen.getByRole('button', { name: '确认当前服务并恢复原任务' }),
);
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeNull(),
);
expect(
await screen.findByRole('region', { name: '快速编辑图片' }),
).toBeTruthy();
});
it('任一独立 modal 打开时都隔离背景焦点并阻断生成与提交端口', async () => {
const user = userEvent.setup();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const view = renderSurface(memory.host);
await screen.findByText('画布可编辑');
const generateTrigger = screen.getByRole('button', {
name: 'AI 生成图片',
});
generateTrigger.focus();
await user.click(generateTrigger);
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expectAssetCanvasBackgroundLocked(true);
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '关闭图片生成面板' }),
),
);
await user.type(
screen.getByRole('textbox', { name: '图片提示词' }),
'角色立绘',
);
await user.click(screen.getByRole('button', { name: '继续确认' }));
expect(screen.getByRole('dialog', { name: '确认图片生成' })).toBeTruthy();
const generationFirst = screen.getByRole('button', {
name: '关闭图片生成面板',
});
const generationLast = screen.getByRole('button', { name: '确认并生成' });
await waitFor(() => expect(document.activeElement).toBe(generationFirst));
generationLast.focus();
await user.tab();
expect(document.activeElement).toBe(generationFirst);
await user.tab({ shift: true });
expect(document.activeElement).toBe(generationLast);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '确认图片生成' })).toBeNull(),
);
expect(document.activeElement).toBe(generateTrigger);
fireEvent.click(screen.getByRole('button', { name: '选择图层 第一层' }));
const exitTrigger = screen.getByRole('button', { name: '取消并返回' });
exitTrigger.focus();
await user.click(exitTrigger);
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
expectAssetCanvasBackgroundLocked(true);
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '继续编辑' }),
),
);
const exitFirst = screen.getByRole('button', { name: '放弃草稿' });
const exitLast = screen.getByRole('button', { name: '保留草稿并退出' });
exitLast.focus();
await user.tab();
expect(document.activeElement).toBe(exitFirst);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '返回资源总览' })).toBeNull(),
);
expect(document.activeElement).toBe(exitTrigger);
view.unmount();
const identityMemory = memoryHost({
initialDraft: draftFixture(scope),
serviceIdentityConfirmation: {
generationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
operationId: 'operation-one',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-one-that-is-long-enough-for-the-contract',
expiresAt: Date.now() + 60_000,
},
});
renderSurface(identityMemory.host);
expect(
await screen.findByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeTruthy();
expectAssetCanvasBackgroundLocked(true);
expectLockedBackgroundCannotCallPaidPorts(identityMemory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '暂不恢复旧任务' }),
),
);
const serviceFirst = screen.getByRole('button', {
name: '暂不恢复旧任务',
});
const serviceLast = screen.getByRole('button', {
name: '确认当前服务并恢复原任务',
});
serviceLast.focus();
await user.tab();
expect(document.activeElement).toBe(serviceFirst);
await user.keyboard('{Escape}');
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeNull(),
);
expect(document.activeElement).toBe(
screen.getByRole('button', { name: 'AI 生成图片' }),
);
});
it('原 generation 在后台恢复时草稿立即可编辑且恢复任务仍继续', async () => {
const recoverImagesGate = deferred<unknown>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
recoverImagesGate,
});
renderSurface(memory.host);
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
expect(
screen.getByRole('region', { name: '素材创作无限画布' }).dataset.state,
).toBe('canvas.editing');
expect(screen.getByRole('button', { name: 'AI 生成图片' })).toBeTruthy();
expect(
document
.querySelector('.asset-canvas-surface__viewport')
?.hasAttribute('inert'),
).toBe(false);
expect(memory.generationCalls).toHaveLength(0);
expect(memory.commits).toHaveLength(0);
expect(screen.getByText('画布可编辑')).toBeTruthy();
recoverImagesGate.resolve(undefined);
await act(async () => Promise.resolve());
expect(memory.recoverImages).toHaveBeenCalledTimes(1);
});
it('保留可编辑名称,并把新建资源用途限制为中文固定选项', async () => {
const memory = memoryHost();
renderSurface(memory.host);
await screen.findByText('画布可编辑');
const assetName = screen.getByRole('textbox', { name: '素材名称' });
const assetKind = screen.getByRole('combobox', { name: '资源用途' });
expect((assetName as HTMLInputElement).value).toBe('画布素材');
expect((assetKind as HTMLSelectElement).value).toBe('game-art');
expect((assetKind as HTMLSelectElement).disabled).toBe(false);
expect(
Array.from((assetKind as HTMLSelectElement).options).map(
(option) => option.text,
),
).toEqual(['普通游戏美术', '统一视觉规范', '游戏界面原型', '核心美术图集']);
fireEvent.change(assetName, { target: { value: '主界面草图' } });
fireEvent.change(assetKind, { target: { value: 'ui-prototype' } });
fireEvent.click(screen.getByRole('button', { name: '保存到项目' }));
await waitFor(() => expect(memory.commits).toHaveLength(1));
expect(memory.commits[0]).toEqual(
expect.objectContaining({
assetName: '主界面草图',
assetKind: 'ui-prototype',
}),
);
});
it('精修隐藏保存设置,并以继承的名称和用途提交选中候选图', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'candidate-layer',
resourceId: 'draft-media:candidate',
title: '候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'candidate',
mediaType: 'image/png' as const,
sha256: 'c'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const sourceLayer = {
...keyboardCanvas().layers[1],
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '源资源',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
};
const refineCanvas = {
...emptyCanvas(),
layers: [sourceLayer, candidateLayer],
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, refineCanvas),
});
renderSurface(memory.host, refineScope, vi.fn(), vi.fn(), vi.fn(), {
name: '角色立绘',
kind: 'illustration',
});
await screen.findByText('画布可编辑');
expect(screen.queryByRole('textbox', { name: '素材名称' })).toBeNull();
expect(screen.queryByRole('combobox', { name: '资源用途' })).toBeNull();
expect(screen.queryByRole('button', { name: 'AI 生成图片' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '选择图层 候选图' }));
fireEvent.click(screen.getAllByRole('button', { name: '设为最终图' })[0]!);
await waitFor(() =>
expect(memory.selectedCandidateCommits).toHaveLength(1),
);
expect(memory.selectedCandidateCommits[0]).toEqual(
expect.objectContaining({
sourceLayerId: 'candidate-layer',
assetName: '角色立绘',
assetKind: 'illustration',
}),
);
});
it('让生成、失败、保存与取消区域使用内部滚动而不被固定高度裁剪', () => {
const css = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css',
),
'utf8',
);
expect(css).toMatch(
/\.asset-canvas-surface__generation-dialog\s*\{[^}]*max-height:\s*calc\(100% - 32px\)[^}]*overflow:\s*auto/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__operation-overlay\s*\{[^}]*overflow:\s*auto/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__operation-card\s*\{[^}]*max-height:\s*100%[^}]*overflow:\s*auto/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__status\s*\{[^}]*max-height:\s*96px[^}]*overflow-y:\s*auto/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__savebar\s*\{[^}]*grid-template-columns:\s*minmax\(150px, 1\.35fr\) minmax\(118px, 1fr\) minmax\(88px, 0\.55fr\)\s*max-content/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__savebar\s*\{[^}]*width:\s*100%[^}]*max-width:\s*620px[^}]*align-self:\s*flex-end[^}]*margin-inline-start:\s*auto/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__save-field > span\s*\{[^}]*font-size:\s*11px[^}]*white-space:\s*nowrap/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__savebar input,\s*\.asset-canvas-surface__savebar select\s*\{[^}]*font-size:\s*12px[^}]*font-weight:\s*600/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__toolbar-shell\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*column[^}]*align-items:\s*stretch/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__toolbar-shell\s*\{[^}]*gap:\s*5px[^}]*padding:\s*5px 8px 6px/s,
);
expect(css).toMatch(
/\.asset-canvas-surface__toolbar\s+\.genarrative-image-canvas__chrome-button\s*\{[^}]*width:\s*30px[^}]*height:\s*30px/s,
);
expect(css).toMatch(
/\.asset-canvas-surface \.asset-canvas-surface__save\s*\{[^}]*min-width:\s*max-content[^}]*white-space:\s*nowrap/s,
);
expect(css).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.asset-canvas-surface__save\s*\{[^}]*grid-column:\s*1 \/ -1[^}]*width:\s*100%/s,
);
expect(css).toMatch(
/@container \(max-width: 620px\)[\s\S]*?\.asset-canvas-surface__save\s*\{[^}]*grid-column:\s*1 \/ -1[^}]*width:\s*100%/s,
);
});
it('无未保存修改时保留草稿并直接返回资源总览', async () => {
const memory = memoryHost();
const { onCancel } = renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'kept',
});
expect(memory.discardDraft).not.toHaveBeenCalled();
expect(memory.getDraft()?.status).toBe('editing');
});
it('有未保存修改时使用独立确认面板,并可保存草稿后退出', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const { onCancel } = renderSurface(memory.host);
const layer = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.click(layer);
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '保留草稿并退出' }));
await waitFor(() =>
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'kept',
}),
);
expect(memory.updates.length).toBeGreaterThan(0);
expect(memory.discardDraft).not.toHaveBeenCalled();
});
it('草稿自动保存失败后返回仍要求明确保留或放弃', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
updateFailure: {
code: 'fixture-save-failed',
message: '测试草稿保存失败',
},
});
const { onCancel } = renderSurface(memory.host);
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 第一层' }),
);
expect(
(await screen.findByRole('alert', { name: '草稿保存失败' })).textContent,
).toContain('测试草稿保存失败');
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
});
it('素材提交失败只提供安全恢复,不会触发新生成', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
commitFailure: {
code: 'result-unknown',
message: '提交结果未知,请恢复原事务',
},
});
renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: '保存到项目' }));
const failure = await screen.findByRole('alert', {
name: '素材提交未完成',
});
expect(failure.textContent).toContain('提交结果未知');
const operationPortal = failure.closest(
'.asset-canvas-surface__operation-portal',
);
expect(operationPortal?.parentElement).toBe(document.body);
expect(
failure.classList.contains(
'asset-canvas-surface__operation-overlay--fullscreen',
),
).toBe(true);
expect(
screen.getByRole('button', { name: '重新加载并安全恢复' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expect(memory.generationCalls).toHaveLength(0);
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
});
it('素材名称校验失败不进入安全恢复流程', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'candidate-layer',
resourceId: 'draft-media:candidate',
title: '候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'candidate',
mediaType: 'image/png' as const,
sha256: 'c'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [candidateLayer],
}),
commitFailure: {
code: 'asset-name-invalid',
message: '素材名称无效',
},
});
renderSurface(memory.host, refineScope);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: '选择图层 候选图' }));
fireEvent.click(screen.getByRole('button', { name: '设为最终图' }));
const failure = await screen.findByRole('alert', {
name: '素材名称无效',
});
expect(failure.textContent).toContain('素材名称无效');
expect(
screen.queryByRole('button', { name: '重新加载并安全恢复' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
await screen.findByText('画布可编辑');
expect(screen.getByText(/素材名称无效/)).toBeTruthy();
});
it('恢复与取消故障使用各自的可访问名称和允许动作', async () => {
const recoveryMemory = memoryHost({
initialDraft: draftFixture(scope),
recoverFailure: {
code: 'reconciliation-required',
message: '原任务需要对账',
},
});
const recoveryView = renderSurface(recoveryMemory.host);
expect(
await screen.findByRole('alert', { name: '原任务恢复未完成' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(recoveryMemory);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(recoveryView.onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'kept',
});
expect(recoveryMemory.discardDraft).not.toHaveBeenCalled();
recoveryView.unmount();
const cancellationMemory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
discardFailure: {
code: 'cancel-conflict',
message: '草稿取消发生冲突',
},
});
renderSurface(cancellationMemory.host);
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 第一层' }),
);
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
fireEvent.click(screen.getByRole('button', { name: '放弃草稿' }));
expect(
await screen.findByRole('alert', { name: '草稿处置失败' }),
).not.toBeNull();
expect(
screen.getByRole('button', { name: '保留草稿继续编辑' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(cancellationMemory);
});
it('只有明确放弃草稿时才写入 cancelled', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const { onCancel } = renderSurface(memory.host);
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 第一层' }),
);
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
fireEvent.click(screen.getByRole('button', { name: '放弃草稿' }));
await waitFor(() => expect(memory.discardDraft).toHaveBeenCalledTimes(1));
expect(onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
disposition: 'discarded',
});
expect(memory.getDraft()?.status).toBe('cancelled');
});
it('键盘点击直接打开快速编辑,Shift 键盘手势不误开卡片', async () => {
const user = userEvent.setup();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
first.focus();
await user.keyboard('{Enter}');
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(
await screen.findByRole('region', { name: '快速编辑图片' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭快速编辑' }));
second.focus();
await user.keyboard(' ');
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(first.getAttribute('aria-pressed')).toBe('false');
expect(
await screen.findByRole('region', { name: '快速编辑图片' }),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭快速编辑' }));
first.focus();
await user.keyboard('{Shift>}{Enter}{/Shift}');
expect(first.getAttribute('aria-pressed')).toBe('false');
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull();
const resize = screen.getByRole('button', { name: '缩放图层 第二层' });
resize.focus();
await user.keyboard('{ArrowRight}');
await waitFor(() => {
const resized = memory
.getDraft()
?.canvas.layers.find((layer) => layer.layerId === 'keyboard-layer-2');
expect(resized?.width).toBeGreaterThan(40);
});
});
it('Shift 指针多选只切换选择,不误开快速编辑卡', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.pointerDown(first, {
pointerId: 1,
clientX: 10,
clientY: 10,
shiftKey: true,
});
fireEvent.pointerUp(window, {
pointerId: 1,
clientX: 10,
clientY: 10,
shiftKey: true,
});
fireEvent.click(first, { detail: 1, shiftKey: true });
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(second.getAttribute('aria-pressed')).toBe('false');
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull();
fireEvent.pointerDown(second, {
pointerId: 2,
clientX: 70,
clientY: 10,
shiftKey: true,
});
fireEvent.pointerUp(window, {
pointerId: 2,
clientX: 70,
clientY: 10,
shiftKey: true,
});
fireEvent.click(second, { detail: 1, shiftKey: true });
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull();
fireEvent.pointerDown(first, {
pointerId: 3,
clientX: 10,
clientY: 10,
shiftKey: true,
});
fireEvent.pointerUp(window, {
pointerId: 3,
clientX: 10,
clientY: 10,
shiftKey: true,
});
fireEvent.click(first, { detail: 1, shiftKey: true });
expect(first.getAttribute('aria-pressed')).toBe('false');
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull();
});
it('零位移指针序列不写历史、不推进草稿,首次有效移动只写一个撤销项', async () => {
const canvas = keyboardCanvas();
canvas.selectedLayerIds = ['keyboard-layer-1'];
canvas.primarySelectedLayerId = 'keyboard-layer-1';
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(first, {
pointerId: 10,
clientX: 10,
clientY: 10,
});
fireEvent.pointerUp(window, {
pointerId: 10,
clientX: 10,
clientY: 10,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect((undo as HTMLButtonElement).disabled).toBe(true);
fireEvent.pointerDown(first, {
pointerId: 11,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(window, {
pointerId: 11,
clientX: 20,
clientY: 20,
});
fireEvent.pointerMove(window, {
pointerId: 11,
clientX: 30,
clientY: 30,
});
fireEvent.pointerUp(window, {
pointerId: 11,
clientX: 30,
clientY: 30,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(1));
expect((undo as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(undo);
await waitFor(() => {
expect(memory.getDraft()?.canvas.layers[0]?.x).toBe(0);
expect(
(screen.getByRole('button', { name: '撤销' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
});
it('图层拖离后回到起点不写历史或草稿', async () => {
const canvas = keyboardCanvas();
canvas.selectedLayerIds = ['keyboard-layer-1'];
canvas.primarySelectedLayerId = 'keyboard-layer-1';
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const firstLayer = screen.getByRole('group', { name: '图层 第一层' });
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(first, {
pointerId: 15,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(window, {
pointerId: 15,
clientX: 30,
clientY: 40,
});
await waitFor(() => expect(firstLayer.style.left).not.toBe('0px'));
fireEvent.pointerMove(window, {
pointerId: 15,
clientX: 10,
clientY: 10,
});
await waitFor(() => {
expect(firstLayer.style.left).toBe('0px');
expect(firstLayer.style.top).toBe('0px');
});
fireEvent.pointerUp(window, {
pointerId: 15,
clientX: 10,
clientY: 10,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect((undo as HTMLButtonElement).disabled).toBe(true);
});
it('画布平移拖离后回到起点不写历史或草稿', async () => {
const memory = memoryHost({ initialDraft: draftFixture(scope) });
renderSurface(memory.host);
await screen.findByText('画布可编辑');
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
const world = document.querySelector(
'.genarrative-image-canvas__world',
) as HTMLElement;
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(viewport, {
pointerId: 16,
clientX: 20,
clientY: 30,
});
fireEvent.pointerMove(window, {
pointerId: 16,
clientX: 50,
clientY: 60,
});
await waitFor(() =>
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: 30, y: 30, scale: 0.5 }),
),
);
fireEvent.pointerMove(window, {
pointerId: 16,
clientX: 20,
clientY: 30,
});
await waitFor(() =>
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }),
),
);
fireEvent.pointerUp(window, {
pointerId: 16,
clientX: 20,
clientY: 30,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect((undo as HTMLButtonElement).disabled).toBe(true);
});
it('图层缩放拖离后回到起点不写历史或草稿', async () => {
const canvas = keyboardCanvas();
canvas.selectedLayerIds = ['keyboard-layer-1'];
canvas.primarySelectedLayerId = 'keyboard-layer-1';
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
});
renderSurface(memory.host);
const resize = await screen.findByRole('button', {
name: '缩放图层 第一层',
});
const firstLayer = screen.getByRole('group', { name: '图层 第一层' });
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(resize, {
pointerId: 17,
clientX: 40,
clientY: 30,
});
fireEvent.pointerMove(window, {
pointerId: 17,
clientX: 60,
clientY: 50,
});
await waitFor(() => expect(firstLayer.style.width).not.toBe('40px'));
fireEvent.pointerMove(window, {
pointerId: 17,
clientX: 40,
clientY: 30,
});
await waitFor(() => {
expect(firstLayer.style.width).toBe('40px');
expect(firstLayer.style.height).toBe('30px');
});
fireEvent.pointerUp(window, {
pointerId: 17,
clientX: 40,
clientY: 30,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect((undo as HTMLButtonElement).disabled).toBe(true);
});
it('缩放零位移后 pointercancel 不写历史或草稿', async () => {
const canvas = keyboardCanvas();
canvas.selectedLayerIds = ['keyboard-layer-1'];
canvas.primarySelectedLayerId = 'keyboard-layer-1';
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
});
renderSurface(memory.host);
const resize = await screen.findByRole('button', {
name: '缩放图层 第一层',
});
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(resize, {
pointerId: 12,
clientX: 40,
clientY: 30,
});
fireEvent.pointerMove(window, {
pointerId: 12,
clientX: 40,
clientY: 30,
});
fireEvent.pointerCancel(window, {
pointerId: 12,
clientX: 40,
clientY: 30,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect(memory.getDraft()?.canvas.layers[0]?.width).toBe(40);
expect(memory.getDraft()?.canvas.layers[0]?.height).toBe(30);
expect((undo as HTMLButtonElement).disabled).toBe(true);
});
it('拖动已选中的锁定图层不写历史或草稿', async () => {
const canvas = keyboardCanvas();
canvas.layers[0] = { ...canvas.layers[0]!, locked: true };
canvas.selectedLayerIds = ['keyboard-layer-1'];
canvas.primarySelectedLayerId = 'keyboard-layer-1';
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
});
renderSurface(memory.host);
const lockedLayer = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(lockedLayer, {
pointerId: 13,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(window, {
pointerId: 13,
clientX: 80,
clientY: 90,
});
fireEvent.pointerCancel(window, {
pointerId: 13,
clientX: 80,
clientY: 90,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect(memory.getDraft()?.canvas.layers[0]?.x).toBe(0);
expect(memory.getDraft()?.canvas.layers[0]?.y).toBe(0);
expect((undo as HTMLButtonElement).disabled).toBe(true);
});
it('背景零位移不产生空撤销,有效平移才会保存视口', async () => {
const memory = memoryHost({ initialDraft: draftFixture(scope) });
renderSurface(memory.host);
await screen.findByText('画布可编辑');
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
const undo = screen.getByRole('button', { name: '撤销' });
fireEvent.pointerDown(viewport, {
pointerId: 12,
clientX: 40,
clientY: 40,
});
fireEvent.pointerUp(window, {
pointerId: 12,
clientX: 40,
clientY: 40,
});
await act(
async () =>
await new Promise((resolve) => window.setTimeout(resolve, 240)),
);
expect(memory.updateDraft).not.toHaveBeenCalled();
expect((undo as HTMLButtonElement).disabled).toBe(true);
fireEvent.pointerDown(viewport, {
pointerId: 13,
clientX: 40,
clientY: 40,
});
fireEvent.pointerMove(window, {
pointerId: 13,
clientX: 70,
clientY: 55,
});
fireEvent.pointerUp(window, {
pointerId: 13,
clientX: 70,
clientY: 55,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(1));
expect(memory.getDraft()?.canvas.viewport).toEqual({
x: 30,
y: 15,
scale: 0.5,
});
});
it('普通指针拖动已选多图层时保持选择并同步移动', async () => {
const canvas = keyboardCanvas();
canvas.selectedLayerIds = ['keyboard-layer-1', 'keyboard-layer-2'];
canvas.primarySelectedLayerId = 'keyboard-layer-1';
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.pointerDown(first, {
pointerId: 14,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(window, {
pointerId: 14,
clientX: 20,
clientY: 25,
});
fireEvent.pointerUp(window, {
pointerId: 14,
clientX: 20,
clientY: 25,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(1));
expect(memory.getDraft()?.canvas.selectedLayerIds).toEqual([
'keyboard-layer-1',
'keyboard-layer-2',
]);
expect(
memory.getDraft()?.canvas.layers.map((layer) => [layer.x, layer.y]),
).toEqual([
[20, 30],
[80, 30],
]);
});
it('Tauri 画布滚轮支持二维平移、Shift 横移和 Ctrl 缩放', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
renderSurface(memory.host);
await screen.findByRole('button', { name: '选择图层 第一层' });
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
const world = document.querySelector(
'.genarrative-image-canvas__world',
) as HTMLElement;
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }),
);
fireEvent.wheel(viewport, {
deltaX: 4,
deltaY: 10,
clientX: 100,
clientY: 80,
});
await waitFor(() =>
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: -4, y: -10, scale: 0.5 }),
),
);
fireEvent.wheel(viewport, {
deltaX: 0,
deltaY: 10,
clientX: 100,
clientY: 80,
shiftKey: true,
});
await waitFor(() =>
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({ x: -14, y: -10, scale: 0.5 }),
),
);
fireEvent.wheel(viewport, {
deltaX: 0,
deltaY: -10,
clientX: 100,
clientY: 80,
ctrlKey: true,
});
await waitFor(() =>
expect(world.style.transform).toBe(
canvasViewportToWorldTransform({
x: 100 - 228 * 0.55,
y: 80 - 180 * 0.55,
scale: 0.55,
}),
),
);
});
it('点击导入按钮走原生批量事务并立即选择权威图层', async () => {
const memory = memoryHost();
renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '导入图片' }));
expect(await screen.findByLabelText('图层 native-import.png')).toBeTruthy();
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(1);
expect(memory.nativeImportCalls).toHaveBeenCalledWith(
expect.objectContaining({
expectedDraftRevision: 0,
viewportSize: expect.objectContaining({ width: 900, height: 640 }),
}),
);
expect(
screen
.getByRole('button', { name: '选择图层 native-import.png' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByText('已导入 1 张图片')).toBeTruthy();
});
it('生成任务列表复用美术画布双 Tab,并可折叠为独立按钮', async () => {
const draft = draftFixture(scope);
draft.generations = [
{
generationId: '33333333-3333-4333-8333-333333333333',
intentId: '44444444-4444-4444-8444-444444444444',
phase: 'failed',
referenceResourceIds: [],
outputAssetId: null,
sourceLayerId: null,
placeholder: null,
errorCode: 'unsupported-source-kind',
createdAt: 1,
updatedAt: 2,
},
];
const memory = memoryHost({ initialDraft: draft });
renderSurface(memory.host);
const sidebar = await screen.findByLabelText('画布任务列表');
expect(within(sidebar).getByText('排队/生成中')).toBeTruthy();
const completedTab = within(sidebar).getByRole('tab', { name: '已完成 1' });
fireEvent.click(completedTab);
expect(within(sidebar).getByText(/unsupported-source-kind/)).toBeTruthy();
const toggle = within(sidebar).getByRole('button', { name: '任务列表' });
expect(toggle.getAttribute('aria-expanded')).toBe('true');
fireEvent.click(toggle);
expect(screen.queryByText(/unsupported-source-kind/)).toBeNull();
expect(screen.getByLabelText('画布任务列表')).toBeTruthy();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(toggle);
expect(await screen.findByText(/unsupported-source-kind/)).toBeTruthy();
});
it('明确失败任务可从侧栏删除并同步权威草稿', async () => {
const draft = draftFixture(scope);
draft.generations = [
{
generationId: '55555555-5555-4555-8555-555555555555',
intentId: '66666666-6666-4666-8666-666666666666',
phase: 'failed',
referenceResourceIds: [],
outputAssetId: null,
sourceLayerId: null,
placeholder: { x: 100, y: 100, width: 320, height: 180 },
errorCode: 'unsupported-source-kind',
createdAt: 1,
updatedAt: 2,
},
];
const memory = memoryHost({ initialDraft: draft });
renderSurface(memory.host);
const sidebar = await screen.findByLabelText('画布任务列表');
fireEvent.click(within(sidebar).getByRole('tab', { name: '已完成 1' }));
expect(within(sidebar).getByText(/unsupported-source-kind/)).toBeTruthy();
fireEvent.click(
within(sidebar).getByRole('button', { name: '删除失败任务 55555555' }),
);
expect(await screen.findByText('已删除失败任务')).toBeTruthy();
expect(screen.getByLabelText('画布任务列表')).toBeTruthy();
expect(within(sidebar).queryByText(/unsupported-source-kind/)).toBeNull();
expect(within(sidebar).getByText('暂无任务')).toBeTruthy();
expect(memory.getDraft()?.generations).toHaveLength(0);
});
it('导入图片后通过快速编辑卡删除,并支持撤销重做与正式保存', async () => {
const memory = memoryHost();
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
const file = new File([Uint8Array.from([1, 2, 3])], 'fixture.png', {
type: 'image/png',
});
Object.defineProperty(file, 'arrayBuffer', {
configurable: true,
value: async () => Uint8Array.from([1, 2, 3]).buffer,
});
fireEvent.change(screen.getByLabelText('导入本地图片'), {
target: { files: [file] },
});
expect(await screen.findByLabelText('图层 fixture.png')).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: '选择图层 fixture.png' }),
);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
await waitFor(() =>
expect(screen.queryByLabelText('图层 fixture.png')).toBeNull(),
);
expect(screen.queryByRole('region', { name: '快速编辑图片' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
expect(await screen.findByLabelText('图层 fixture.png')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重做' }));
await waitFor(() =>
expect(screen.queryByLabelText('图层 fixture.png')).toBeNull(),
);
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
fireEvent.click(screen.getByRole('button', { name: '保存到项目' }));
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
expect(onSaveAttempt).toHaveBeenCalledTimes(1);
expect(onSaveAttempt).toHaveBeenCalledWith(
expect.objectContaining({
projectId: scope.projectId,
draftId: scope.draftId,
commitId: memory.commits[0]!.commitId,
}),
);
expect(onCommitted).toHaveBeenCalledWith(
expect.objectContaining({
source: 'command',
projectPath: '/fixture/project',
projectId: scope.projectId,
draftId: scope.draftId,
commitId: memory.commits[0]!.commitId,
assetId: 'local-asset:canvas-commit',
projectRevision: 1,
}),
);
expect(memory.commits).toHaveLength(1);
expect(memory.updates.at(-1)?.layers).toHaveLength(1);
expect(screen.getByText(/已保存到项目 assets/)).toBeTruthy();
});
it('导入回包到达时 modal 已打开则不追加未计 revision 的图层', async () => {
const importGate = deferred<unknown>();
const memory = memoryHost({ importGate });
renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
const file = new File([Uint8Array.from([1, 2, 3])], 'late.png', {
type: 'image/png',
});
Object.defineProperty(file, 'arrayBuffer', {
configurable: true,
value: async () => Uint8Array.from([1, 2, 3]).buffer,
});
fireEvent.change(screen.getByLabelText('导入本地图片'), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).not.toBeNull();
await act(async () => {
importGate.resolve(undefined);
await importGate.promise;
});
await waitFor(() =>
expect(screen.queryByLabelText('图层 late.png')).toBeNull(),
);
expect(memory.updates).toHaveLength(0);
});
it('保存连点保持 single-flight,命令事件重放不重复投影', async () => {
const commitGate = deferred<void>();
const canvas = emptyCanvas();
canvas.layers.push({
layerId: 'layer-one',
resourceId: 'draft-media:one',
title: '已有图层',
mediaRef: {
kind: 'draft-media',
mediaId: 'one',
mediaType: 'image/png',
sha256: 'b'.repeat(64),
byteLength: 4,
pixelWidth: 4,
pixelHeight: 3,
},
x: 0,
y: 0,
width: 4,
height: 3,
originalWidth: 4,
originalHeight: 3,
zIndex: 0,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
});
const memory = memoryHost({
initialDraft: draftFixture(scope, canvas),
commitGate,
});
const { onCommitted } = renderSurface(memory.host);
await screen.findByLabelText('图层 已有图层');
const save = screen.getByRole('button', { name: '保存到项目' });
fireEvent.click(save);
fireEvent.click(save);
await waitFor(() => expect(memory.commits).toHaveLength(1));
commitGate.resolve();
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
memory.emit({
schemaVersion: 'game-creator-local-asset-committed.v1',
eventId: 'event-one',
projectPath: '/moved/project',
projectId: scope.projectId,
committedProjectRevision: 1,
draftId: scope.draftId,
commitId: memory.commits[0]!.commitId,
idempotencyKey: memory.commits[0]!.idempotencyKey,
asset: manifestFixture(scope.projectId).assets[0]!,
manifest: manifestFixture(scope.projectId),
occurredAt: 1,
});
expect(onCommitted).toHaveBeenCalledTimes(1);
});
it('重启恢复现有草稿,并在项目切换后丢弃旧 Promise 和事件', async () => {
const oldScope = scope;
const oldCanvas = emptyCanvas();
oldCanvas.layers.push({
layerId: 'old-layer',
resourceId: 'old-resource',
title: '旧项目图层',
mediaRef: { kind: 'project-asset', assetId: 'old-asset' },
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 0,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
});
const gate = deferred<unknown>();
const oldMemory = memoryHost({
initialDraft: draftFixture(oldScope, oldCanvas),
recoverGate: gate,
});
const nextScope: ImageCanvasHostScope = {
...scope,
projectId: 'project-two',
draftId: '33333333-3333-4333-8333-333333333333',
};
const nextMemory = memoryHost({ initialDraft: draftFixture(nextScope) });
const onCommitted = vi.fn();
const view = renderSurface(oldMemory.host, oldScope, onCommitted);
view.rerender(
<AssetCanvasSurface
host={nextMemory.host}
scope={nextScope}
sessionId="44444444-4444-4444-8444-444444444444"
expectedHostRevision="0"
onCommitted={onCommitted}
renderImage={renderImage}
/>,
);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
await act(async () => gate.resolve(undefined));
expect(screen.queryByLabelText('图层 旧项目图层')).toBeNull();
oldMemory.emit({
schemaVersion: 'game-creator-local-asset-committed.v1',
eventId: 'old-event',
projectPath: '/old',
projectId: oldScope.projectId,
committedProjectRevision: 1,
draftId: oldScope.draftId,
commitId: 'old-commit',
idempotencyKey: 'old-key',
asset: manifestFixture(oldScope.projectId).assets[0]!,
manifest: manifestFixture(oldScope.projectId),
occurredAt: 1,
});
expect(onCommitted).not.toHaveBeenCalled();
});
it('确认前零生成调用,并保持小地图/复位和 1280×800 基础布局可用', async () => {
const canvas = emptyCanvas();
canvas.layers.push({
layerId: 'layout-layer',
resourceId: 'layout-resource',
title: '布局图层',
mediaRef: { kind: 'project-asset', assetId: 'layout-asset' },
x: 100,
y: 100,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 0,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
});
const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) });
renderSurface(memory.host);
await screen.findByLabelText('图层 布局图层');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expect(memory.generationCalls).toHaveLength(0);
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '一张原创游戏场景插画' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
expect(screen.getByRole('dialog', { name: '确认图片生成' })).toBeTruthy();
expect(memory.generationCalls).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: '返回修改' }));
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(memory.generationCalls).toHaveLength(0);
expect(screen.getByRole('button', { name: /复位/ })).toBeTruthy();
expect(
document.querySelector('.genarrative-image-canvas__minimap'),
).not.toBeNull();
const css = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/asset-canvas/assetCanvasSurface.css',
),
'utf8',
);
expect(css).toContain('height: 100%');
expect(css).toContain('min-height: 0');
expect(css).toContain('@media (max-width: 760px)');
});
it('快速编辑修改连点只提交一个生成任务,成功后仅产生草稿候选', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationGate: gate,
});
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '稳定幂等身份的游戏场景' },
});
const modify = within(quickEdit).getByRole('button', { name: '修改' });
fireEvent.click(modify);
fireEvent.click(modify);
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
const identity = memory.generationCalls[0]!;
const uuidV4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const stableIds = [
identity.intentId,
identity.generationId,
identity.idempotencyKey,
identity.commitId,
identity.commitIdempotencyKey,
];
stableIds.forEach((value) => expect(value).toMatch(uuidV4));
expect(new Set(stableIds).size).toBe(5);
expect(onSaveAttempt).toHaveBeenCalledTimes(1);
expect(onCommitted).not.toHaveBeenCalled();
expect(memory.commits).toHaveLength(0);
expect(memory.selectedCandidateCommits).toHaveLength(0);
await act(async () => gate.resolve());
await waitFor(() =>
expect(memory.getDraft()?.generations[0]?.phase).toBe('candidate-ready'),
);
expect(memory.getDraft()?.canvas.layers).toHaveLength(3);
expect(memory.commits).toHaveLength(0);
expect(memory.selectedCandidateCommits).toHaveLength(0);
expect(onCommitted).not.toHaveBeenCalled();
});
it('生成中的进度卡片可拖动,并将位置自动保存到草稿', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationGate: gate,
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '拖动中的候选图片' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
const placeholder = await screen.findByRole('status', {
name: '生成任务:正在生成图片',
});
const initialPlaceholder = memory.getDraft()?.generations[0]?.placeholder;
expect(initialPlaceholder).not.toBeNull();
const initialUpdateCount = memory.updateDraft.mock.calls.length;
fireEvent.pointerDown(placeholder, {
pointerId: 31,
clientX: 100,
clientY: 120,
});
fireEvent.pointerMove(window, {
pointerId: 31,
clientX: 160,
clientY: 160,
});
fireEvent.pointerUp(window, {
pointerId: 31,
clientX: 160,
clientY: 160,
});
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
initialUpdateCount,
),
);
const movedPlaceholder = memory.getDraft()?.generations[0]?.placeholder;
expect(movedPlaceholder).toEqual(
expect.objectContaining({
x: initialPlaceholder!.x + 120,
y: initialPlaceholder!.y + 80,
}),
);
const generationId = memory.generationCalls[0]!.generationId;
await act(async () => gate.resolve());
await waitFor(() =>
expect(
memory
.getDraft()
?.generations.find(
(generation) => generation.generationId === generationId,
)?.phase,
).toBe('candidate-ready'),
);
expect(
memory
.getDraft()
?.generations.find(
(generation) => generation.generationId === generationId,
)?.placeholder,
).toEqual(movedPlaceholder);
});
it('生成期间立即显示占位和任务进度,且画布保持可操作', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationGate: gate,
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '在画布中异步生成新候选' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
const taskSidebar = screen.getByLabelText('画布任务列表');
expect(within(taskSidebar).getByText('正在生成图片')).toBeTruthy();
expect(within(taskSidebar).getByText(/阶段进度 \d+%/)).toBeTruthy();
const placeholder = screen.getByRole('status', {
name: '生成任务:正在生成图片',
});
expect(placeholder.classList.contains('is-failed')).toBe(false);
expect(memory.generationCalls[0]?.sourceLayerId).toBe('keyboard-layer-1');
expect(memory.generationCalls[0]?.placeholder).toEqual(
expect.objectContaining({
x: expect.any(Number),
y: expect.any(Number),
width: expect.any(Number),
height: expect.any(Number),
}),
);
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
expect(viewport.hasAttribute('inert')).toBe(false);
expect((second as HTMLButtonElement).disabled).toBe(false);
fireEvent.pointerDown(second, {
pointerId: 4,
clientX: 70,
clientY: 10,
shiftKey: true,
});
fireEvent.pointerUp(window, {
pointerId: 4,
clientX: 70,
clientY: 10,
shiftKey: true,
});
fireEvent.click(second, { detail: 1, shiftKey: true });
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(second.getAttribute('aria-pressed')).toBe('true');
await act(async () => gate.resolve());
await screen.findByRole('button', { name: /选择图层 .*候选图/ });
expect(screen.queryByRole('status', { name: /^生成任务:/ })).toBeNull();
fireEvent.click(within(taskSidebar).getByRole('tab', { name: '已完成 1' }));
expect(within(taskSidebar).getByText('候选图片已加入画布')).toBeTruthy();
expect(within(taskSidebar).getByText('阶段进度 100%')).toBeTruthy();
});
it('deferred generation 完成后保留期间的图层移动、viewport 平移和 selection', async () => {
const gate = deferred<void>();
const initialCanvas = keyboardCanvas();
const memory = memoryHost({
initialDraft: draftFixture(scope, initialCanvas),
generationGate: gate,
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '保留本地编辑状态的异步候选' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
fireEvent.pointerDown(first, {
pointerId: 51,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(window, {
pointerId: 51,
clientX: 140,
clientY: 130,
});
fireEvent.pointerUp(window, {
pointerId: 51,
clientX: 140,
clientY: 130,
});
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
fireEvent.pointerDown(viewport, {
pointerId: 52,
clientX: 200,
clientY: 200,
});
fireEvent.pointerMove(window, {
pointerId: 52,
clientX: 230,
clientY: 220,
});
fireEvent.pointerUp(window, {
pointerId: 52,
clientX: 230,
clientY: 220,
});
fireEvent.click(second);
await waitFor(() =>
expect(memory.getDraft()?.canvas.selectedLayerIds).toEqual([
'keyboard-layer-2',
]),
);
const editedBeforeCompletion = memory.getDraft()!.canvas;
expect(editedBeforeCompletion.layers[0]).toEqual(
expect.objectContaining({ x: 80, y: 60 }),
);
expect(editedBeforeCompletion.viewport).toEqual(
expect.objectContaining({ x: 30, y: 20 }),
);
await act(async () => gate.resolve());
await waitFor(() =>
expect(memory.getDraft()?.generations[0]?.phase).toBe('candidate-ready'),
);
expect(memory.getDraft()?.canvas.layers[0]).toEqual(
expect.objectContaining({ x: 80, y: 60 }),
);
expect(memory.getDraft()?.canvas.viewport).toEqual(
expect.objectContaining({ x: 30, y: 20 }),
);
expect(memory.getDraft()?.canvas.selectedLayerIds).toEqual([
'keyboard-layer-2',
]);
});
it('生成调用在账本建立前失败时移除占位,刷新后仍可继续编辑', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationFailure: {
code: 'provider-unavailable',
message: '平台暂时不可用',
},
generationFailureNotStarted: true,
});
const view = renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '早期失败的生成请求' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
await waitFor(() => expect(memory.getDraft()?.generations).toHaveLength(0));
expect(memory.settleGenerationFailure).toHaveBeenCalledWith(
expect.objectContaining({
generationId: memory.generationCalls[0]!.generationId,
}),
);
expect(screen.queryByRole('status', { name: /^生成任务:/ })).toBeNull();
expect(
screen
.getByRole('region', { name: '素材创作无限画布' })
.getAttribute('data-state'),
).toBe('canvas.editing');
view.unmount();
renderSurface(memory.host);
await screen.findByText('画布可编辑');
expect(screen.queryByRole('status', { name: /^生成任务:/ })).toBeNull();
expect(
screen
.getByRole('region', { name: '素材创作无限画布' })
.getAttribute('data-state'),
).toBe('canvas.editing');
expect(
screen.getByRole('button', { name: '选择图层 第一层' }),
).toBeTruthy();
});
it('生成失败保留失败任务和占位,画布不锁定且参数可继续修改', async () => {
const prompt = '保留参数的原创游戏场景';
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationFailure: {
code: 'insufficient-mud-points',
message: '泥点余额不足,请充值后重试',
},
});
renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: prompt },
});
fireEvent.change(screen.getByLabelText('图片比例'), {
target: { value: '9:16' },
});
fireEvent.change(screen.getByLabelText('图片尺寸'), {
target: { value: '2K' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
const failedPlaceholder = await screen.findByRole('status', {
name: '生成任务:图片生成失败',
});
expect(failedPlaceholder.classList.contains('is-failed')).toBe(true);
const taskSidebar = screen.getByLabelText('画布任务列表');
fireEvent.click(within(taskSidebar).getByRole('tab', { name: '已完成 1' }));
expect(
taskSidebar.querySelector(
'.asset-canvas-surface__generation-task-item--failed',
),
).toBeTruthy();
expect(
within(taskSidebar).getByText(/insufficient-mud-points/),
).toBeTruthy();
await waitFor(() =>
expect(memory.getDraft()?.generations[0]?.phase).toBe('failed'),
);
expect(memory.getDraft()?.generations[0]?.errorCode).toBe(
'insufficient-mud-points',
);
expect(screen.getByText('泥点余额不足,请充值后重试')).toBeTruthy();
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
const surface = screen.getByRole('region', { name: '素材创作无限画布' });
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
expect(surface.getAttribute('data-state')).toBe('canvas.editing');
expect(viewport.hasAttribute('inert')).toBe(false);
await act(async () => Promise.resolve());
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expect(
(screen.getByLabelText('图片提示词') as HTMLTextAreaElement).value,
).toBe(prompt);
expect((screen.getByLabelText('图片比例') as HTMLSelectElement).value).toBe(
'9:16',
);
expect((screen.getByLabelText('图片尺寸') as HTMLSelectElement).value).toBe(
'2K',
);
expect(
taskSidebar.querySelector(
'.asset-canvas-surface__generation-task-item--failed',
),
).toBeTruthy();
});
it('生成失败需要对账时保持安全恢复阻断态', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationFailure: {
code: 'reconciliation-required',
message: '远端生成结果需要安全对账',
},
});
renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '需要安全对账的游戏场景' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
expect(
await screen.findByRole('alert', { name: '原任务恢复未完成' }),
).not.toBeNull();
expect(
screen.getAllByText('远端生成结果需要安全对账').length,
).toBeGreaterThan(0);
expect(memory.getDraft()?.generations[0]?.phase).toBe(
'reconciliation-required',
);
const surface = screen.getByRole('region', { name: '素材创作无限画布' });
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
expect(surface.getAttribute('data-state')).toBe('canvas.failed');
expect(viewport.hasAttribute('inert')).toBe(true);
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
});
it('快速编辑失败只记录任务错误,已有图片仍可继续编辑', async () => {
const prompt = '登录恢复后继续生成的角色立绘';
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationFailure: {
code: 'authentication-required',
message: '登录已失效,请重新登录后重试',
},
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: prompt },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
await screen.findByRole('status', { name: '生成任务:图片生成失败' });
const taskSidebar = screen.getByLabelText('画布任务列表');
fireEvent.click(within(taskSidebar).getByRole('tab', { name: '已完成 1' }));
expect(
within(taskSidebar).getByText(/authentication-required/),
).toBeTruthy();
await waitFor(() =>
expect(memory.getDraft()?.generations[0]?.phase).toBe('failed'),
);
expect(memory.getDraft()?.generations[0]?.errorCode).toBe(
'authentication-required',
);
expect(screen.getByText('登录已失效,请重新登录后重试')).toBeTruthy();
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
await act(async () => Promise.resolve());
fireEvent.click(second);
const continuedQuickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
expect(
(
within(continuedQuickEdit).getByLabelText(
'图片提示词',
) as HTMLTextAreaElement
).value,
).toBe(prompt);
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(
taskSidebar.querySelector(
'.asset-canvas-surface__generation-task-item--failed',
),
).toBeTruthy();
});
it('精修生成成功只加入候选,显式设为最终图后才正式提交', async () => {
const gate = deferred<void>();
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '入口图片',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
generationGate: gate,
});
const { onCancel, onCommitted } = renderSurface(
memory.host,
refineScope,
vi.fn(),
vi.fn(),
vi.fn(),
{ name: '角色立绘', kind: 'illustration' },
);
const sourceButton = await screen.findByRole('button', {
name: '选择图层 入口图片',
});
fireEvent.click(sourceButton);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '生成一张可选的精修候选' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
expect(onCancel).not.toHaveBeenCalled();
expect(onCommitted).not.toHaveBeenCalled();
expect(memory.selectedCandidateCommits).toHaveLength(0);
await act(async () => gate.resolve());
const candidateButton = await screen.findByRole('button', {
name: '选择图层 角色立绘 候选图',
});
expect(onCommitted).not.toHaveBeenCalled();
expect(memory.commits).toHaveLength(0);
expect(memory.selectedCandidateCommits).toHaveLength(0);
expect(memory.getDraft()?.status).toBe('editing');
fireEvent.click(candidateButton);
const candidateQuickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const setFinal = within(candidateQuickEdit).getByRole('button', {
name: '设为最终图',
});
expect(
within(candidateQuickEdit).getByRole('button', { name: '删除' }),
).toBeTruthy();
expect(
within(candidateQuickEdit).getByRole('button', { name: '修改' }),
).toBeTruthy();
fireEvent.click(setFinal);
await waitFor(() =>
expect(memory.selectedCandidateCommits).toHaveLength(1),
);
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
expect(memory.selectedCandidateCommits[0]).toEqual(
expect.objectContaining({
assetName: '角色立绘',
assetKind: 'illustration',
}),
);
expect(memory.selectedCandidateCommits[0]?.sourceLayerId).toMatch(
/^generated-layer-/,
);
expect(memory.commits).toHaveLength(0);
expect(memory.getDraft()?.status).toBe('editing');
expect(memory.getDraft()?.lastCommit?.sourceLayerId).toBe(
memory.selectedCandidateCommits[0]?.sourceLayerId,
);
expect(screen.getByText('已设为最终图,精修草稿可继续编辑')).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
});
it('候选确认形成导入屏障,确认后删除并再次保存不会被宿主保护逻辑复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer-for-candidate-ack',
resourceId: 'local-asset:source-asset',
title: '候选确认入口图',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
};
const candidateAcknowledgementGate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
candidateAcknowledgementGate,
});
renderSurface(memory.host, refineScope, vi.fn(), vi.fn(), vi.fn(), {
name: '候选确认素材',
kind: 'illustration',
});
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 候选确认入口图' }),
);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '生成后确认再删除' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
const candidateButton = await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
});
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledTimes(1),
);
const candidateLayerId =
memory.acknowledgeCandidateLayers.mock.calls[0]?.[0].layerIds[0];
expect(candidateLayerId).toMatch(/^generated-layer-/);
fireEvent.click(screen.getByRole('button', { name: '导入图片' }));
await act(async () => {
await Promise.resolve();
});
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(0);
await act(async () => {
candidateAcknowledgementGate.resolve();
await candidateAcknowledgementGate.promise;
});
await waitFor(() =>
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(1),
);
const candidateSaveIndex = memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'update' &&
operation.canvas.layers.some(
(layer) => layer.layerId === candidateLayerId,
),
);
expect(candidateSaveIndex).toBeGreaterThanOrEqual(0);
expect(
memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'acknowledge' &&
operation.layerIds.includes(candidateLayerId!),
),
).toBeGreaterThan(candidateSaveIndex);
fireEvent.click(
await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
}),
);
const candidateQuickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(
within(candidateQuickEdit).getByRole('button', { name: '删除' }),
);
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some((layer) => layer.layerId === candidateLayerId),
).toBe(false);
});
it('重新打开 candidate-ready 草稿时先幂等确认,后续删除不会复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'recovered-candidate-layer',
resourceId: 'draft-media:recovered-candidate',
title: '恢复候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'recovered-candidate',
mediaType: 'image/png' as const,
sha256: 'd'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const initialDraft = {
...draftFixture(refineScope, {
...emptyCanvas(),
layers: [candidateLayer],
selectedLayerIds: [candidateLayer.layerId],
primarySelectedLayerId: candidateLayer.layerId,
}),
generations: [
{
generationId: 'recovered-generation',
intentId: 'recovered-intent',
phase: 'candidate-ready' as const,
referenceResourceIds: [],
outputAssetId: null,
sourceLayerId: null,
placeholder: null,
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
],
};
const memory = memoryHost({
initialDraft,
initialUnacknowledgedCandidateLayerIds: [candidateLayer.layerId],
});
renderSurface(memory.host, refineScope);
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledWith({
scope: refineScope,
layerIds: [candidateLayer.layerId],
}),
);
const candidateButton = await screen.findByRole('button', {
name: '选择图层 恢复候选图',
});
await waitFor(() =>
expect((candidateButton as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(candidateButton);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some(
(layer) => layer.layerId === candidateLayer.layerId,
),
).toBe(false);
});
it('精修图片直接修改时保留源资源引用、来源图层和生成占位', async () => {
const refineScope: ImageCanvasHostScope = {
projectId: 'project-refine',
draftId: '12121212-1212-4212-8212-121212121212',
intent: 'refine',
sourceAssetId: 'source-asset',
};
const canvas = emptyCanvas();
canvas.layers.push({
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '精修源图',
mediaRef: { kind: 'project-asset', assetId: 'source-asset' },
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 0,
groupId: null,
hidden: false,
locked: false,
flipX: false,
flipY: false,
});
const memory = memoryHost({
initialDraft: draftFixture(refineScope, canvas),
});
const { onCommitted } = renderSurface(memory.host, refineScope);
await screen.findByLabelText('图层 精修源图');
fireEvent.click(screen.getByRole('button', { name: '选择图层 精修源图' }));
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
expect(within(quickEdit).getByText('参考资源:精修源图')).toBeTruthy();
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '保留构图并调整光影' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
expect(screen.queryByRole('dialog', { name: '确认图片生成' })).toBeNull();
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
expect(memory.generationCalls[0]?.referenceResourceIds).toEqual([
'local-asset:source-asset',
]);
expect(memory.generationCalls[0]?.sourceLayerId).toBe('source-layer');
expect(memory.generationCalls[0]?.placeholder).toEqual(
expect.objectContaining({
x: expect.any(Number),
y: expect.any(Number),
width: expect.any(Number),
height: expect.any(Number),
}),
);
expect(memory.selectedCandidateCommits).toHaveLength(0);
expect(onCommitted).not.toHaveBeenCalled();
});
it('相同 scope 值的父级重渲染不会重开草稿,锁定图层选择仍会持久化', async () => {
const canvas = emptyCanvas();
canvas.layers.push({
layerId: 'locked-layer',
resourceId: 'locked-resource',
title: '锁定图层',
mediaRef: { kind: 'project-asset', assetId: 'locked-asset' },
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 0,
groupId: null,
hidden: false,
locked: true,
flipX: false,
flipY: false,
});
const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) });
const onCommitted = vi.fn();
const view = renderSurface(memory.host, { ...scope }, onCommitted);
const layer = await screen.findByRole('button', {
name: '选择图层 锁定图层',
});
expect(memory.recover).toHaveBeenCalledTimes(1);
expect(memory.loadDraft).toHaveBeenCalledTimes(1);
view.rerender(
<div style={{ width: 1280, height: 800 }}>
<AssetCanvasSurface
host={memory.host}
scope={{ ...scope }}
sessionId="22222222-2222-4222-8222-222222222222"
expectedHostRevision="0"
onCommitted={onCommitted}
renderImage={renderImage}
/>
</div>,
);
await act(async () => Promise.resolve());
expect(memory.recover).toHaveBeenCalledTimes(1);
expect(memory.loadDraft).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(layer, { pointerId: 1, clientX: 10, clientY: 10 });
await waitFor(() =>
expect(memory.updates.at(-1)?.selectedLayerIds).toEqual(['locked-layer']),
);
});
it('乱序 generation loadDraft 只能单调落到最高可信 revision', async () => {
const initialDraft = {
...draftFixture(scope, keyboardCanvas()),
revision: 4,
updatedAt: 4,
};
const revisionFive = {
...initialDraft,
revision: 5,
updatedAt: 5,
};
const revisionSix = {
...initialDraft,
revision: 6,
updatedAt: 6,
};
const loadFive = deferred<{
status: 'ok';
value: ImageCanvasDraft | null;
}>();
const loadSix = deferred<{
status: 'ok';
value: ImageCanvasDraft | null;
}>();
const memory = memoryHost({ initialDraft });
renderSurface(memory.host);
await screen.findByText('画布可编辑');
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
memory.loadDraft.mockImplementationOnce(() => loadFive.promise);
memory.loadDraft.mockImplementationOnce(() => loadSix.promise);
act(() => {
memory.emitRecoveryProgress({
intentId: 'intent-revision-order',
generationId: 'generation-revision-order',
phase: 'generation-running',
progress: 50,
errorCode: null,
draftRevision: 5,
});
memory.emitRecoveryProgress({
intentId: 'intent-revision-order',
generationId: 'generation-revision-order',
phase: 'generation-running',
progress: 60,
errorCode: null,
draftRevision: 6,
});
});
await waitFor(() => expect(memory.loadDraft).toHaveBeenCalledTimes(3));
await act(async () => {
loadSix.resolve({ status: 'ok', value: revisionSix });
await Promise.resolve();
});
await act(async () => {
loadFive.resolve({ status: 'ok', value: revisionFive });
await Promise.resolve();
});
memory.setDraft(revisionSix);
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.pointerDown(second, {
pointerId: 21,
clientX: 70,
clientY: 10,
});
fireEvent.pointerUp(window, {
pointerId: 21,
clientX: 70,
clientY: 10,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(1));
expect(memory.updateDraft.mock.calls[0]?.[0].expectedDraftRevision).toBe(6);
});
it('generation progress 首次读取失败后允许同 revision 重试并应用', async () => {
const initialDraft = {
...draftFixture(scope, keyboardCanvas()),
revision: 4,
updatedAt: 4,
};
const revisionFive = {
...initialDraft,
revision: 5,
updatedAt: 5,
};
const memory = memoryHost({ initialDraft });
renderSurface(memory.host);
await screen.findByText('画布可编辑');
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
memory.loadDraft.mockResolvedValueOnce({ status: 'ok', value: null });
memory.loadDraft.mockResolvedValueOnce({
status: 'ok',
value: revisionFive,
});
const progress: ImageCanvasGenerationProgress = {
intentId: 'intent-revision-retry',
generationId: 'generation-revision-retry',
phase: 'generation-running',
progress: 50,
errorCode: null,
draftRevision: 5,
};
act(() => memory.emitRecoveryProgress(progress));
await waitFor(() => expect(memory.loadDraft).toHaveBeenCalledTimes(2));
act(() => memory.emitRecoveryProgress(progress));
await waitFor(() => expect(memory.loadDraft).toHaveBeenCalledTimes(3));
memory.setDraft(revisionFive);
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.pointerDown(second, {
pointerId: 24,
clientX: 70,
clientY: 10,
});
fireEvent.pointerUp(window, {
pointerId: 24,
clientX: 70,
clientY: 10,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(1));
expect(memory.updateDraft.mock.calls[0]?.[0].expectedDraftRevision).toBe(5);
});
it('草稿保存结果与正序 progress load 交错时拒绝同 revision 内容分叉', async () => {
const initialDraft = {
...draftFixture(scope, keyboardCanvas()),
revision: 4,
updatedAt: 4,
};
const savedGeneration: ImageCanvasDraft['generations'][number] = {
generationId: 'generation-from-save',
intentId: 'intent-interleaved-save',
phase: 'generation-running',
referenceResourceIds: [],
outputAssetId: null,
errorCode: null,
createdAt: 4,
updatedAt: 5,
};
const staleGeneration: ImageCanvasDraft['generations'][number] = {
...savedGeneration,
generationId: 'generation-from-stale-load',
};
const savedDraft: ImageCanvasDraft = {
...initialDraft,
revision: 5,
updatedAt: 5,
canvas: {
...initialDraft.canvas,
selectedLayerIds: ['keyboard-layer-2'],
primarySelectedLayerId: 'keyboard-layer-2',
},
generations: [savedGeneration],
};
const sameRevisionFork: ImageCanvasDraft = {
...savedDraft,
canvas: {
...savedDraft.canvas,
selectedLayerIds: ['keyboard-layer-1'],
primarySelectedLayerId: 'keyboard-layer-1',
},
generations: [staleGeneration],
};
expect(
shouldApplyAssetCanvasDraftCandidate({
current: savedDraft,
candidate: sameRevisionFork,
minimumRevision: 5,
scope,
}),
).toBe(false);
const saveResult =
deferred<Awaited<ReturnType<ImageCanvasProjectPort['updateDraft']>>>();
const progressLoad =
deferred<Awaited<ReturnType<ImageCanvasProjectPort['loadDraft']>>>();
const memory = memoryHost({ initialDraft });
renderSurface(memory.host);
await screen.findByText('画布可编辑');
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
memory.updateDraft.mockImplementationOnce(() => saveResult.promise);
memory.loadDraft.mockImplementationOnce(() => progressLoad.promise);
const second = screen.getByRole('button', { name: '选择图层 第二层' });
fireEvent.pointerDown(second, {
pointerId: 22,
clientX: 70,
clientY: 10,
});
fireEvent.pointerUp(window, {
pointerId: 22,
clientX: 70,
clientY: 10,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(1));
act(() => {
memory.emitRecoveryProgress({
intentId: 'intent-interleaved-save',
generationId: 'generation-from-stale-load',
phase: 'generation-running',
progress: 50,
errorCode: null,
draftRevision: 5,
});
});
await waitFor(() => expect(memory.loadDraft).toHaveBeenCalledTimes(2));
memory.setDraft(savedDraft);
await act(async () => {
saveResult.resolve({ status: 'ok', value: savedDraft });
await Promise.resolve();
});
await act(async () => {
progressLoad.resolve({ status: 'ok', value: sameRevisionFork });
await Promise.resolve();
});
const first = screen.getByRole('button', { name: '选择图层 第一层' });
fireEvent.pointerDown(first, {
pointerId: 23,
clientX: 10,
clientY: 10,
});
fireEvent.pointerUp(window, {
pointerId: 23,
clientX: 10,
clientY: 10,
});
await waitFor(() => expect(memory.updateDraft).toHaveBeenCalledTimes(2));
expect(memory.updateDraft.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({
expectedDraftRevision: 5,
generations: [savedGeneration],
}),
);
});
it('项目切换后丢弃旧 generation 轮询结果,不覆盖新项目状态', async () => {
const gate = deferred<void>();
const oldMemory = memoryHost({
initialDraft: draftFixture(scope),
generationGate: gate,
});
const nextScope: ImageCanvasHostScope = {
...scope,
projectId: 'project-generation-next',
draftId: '55555555-5555-4555-8555-555555555555',
};
const nextMemory = memoryHost({ initialDraft: draftFixture(nextScope) });
const onCommitted = vi.fn();
const view = renderSurface(oldMemory.host, scope, onCommitted);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '旧项目的生成请求' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
await waitFor(() => expect(oldMemory.generationCalls).toHaveLength(1));
view.rerender(
<AssetCanvasSurface
host={nextMemory.host}
scope={nextScope}
sessionId="66666666-6666-4666-8666-666666666666"
expectedHostRevision="0"
onCommitted={onCommitted}
renderImage={renderImage}
/>,
);
await screen.findByText('画布可编辑');
await act(async () => gate.resolve());
expect(onCommitted).not.toHaveBeenCalled();
expect(nextMemory.generationCalls).toHaveLength(0);
});
it('恢复前先建立事件订阅,并投影 recovery 发布的固定事件', async () => {
const recoveryEvent: LocalAssetCommittedEvent = {
schemaVersion: 'game-creator-local-asset-committed.v1',
eventId: 'recovery-event',
projectPath: '/moved/project',
projectId: scope.projectId,
committedProjectRevision: 1,
draftId: scope.draftId,
commitId: 'recovered-commit',
idempotencyKey: 'recovered-key',
asset: manifestFixture(scope.projectId).assets[0]!,
manifest: manifestFixture(scope.projectId),
occurredAt: 1,
};
const memory = memoryHost({
initialDraft: draftFixture(scope),
recoveryEvent,
});
const { onCommitted } = renderSurface(memory.host);
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
expect(onCommitted).toHaveBeenCalledWith(
expect.objectContaining({ eventId: 'recovery-event' }),
);
});
it('旧 Key 账本先显示服务确认面板,确认后才恢复原 operation', async () => {
const user = userEvent.setup();
const confirmation: ImageCanvasGenerationServiceIdentityConfirmation = {
generationId: '33333333-3333-4333-8333-333333333333',
operationId: 'existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-value-that-is-long-enough-for-the-contract',
expiresAt: Date.now() + 60_000,
};
const memory = memoryHost({
initialDraft: draftFixture(scope),
serviceIdentityConfirmation: confirmation,
});
renderSurface(memory.host);
const dialog = await screen.findByRole('dialog', {
name: '确认旧生成任务服务',
});
expect(dialog.textContent).toContain('https://editor.example.test');
expect(dialog.textContent).toContain('平台已受理');
expect(memory.confirmGenerationServiceIdentity).not.toHaveBeenCalled();
expect(memory.recoverImages).toHaveBeenCalledTimes(1);
await user.click(
screen.getByRole('button', { name: '确认当前服务并恢复原任务' }),
);
await waitFor(() =>
expect(memory.confirmGenerationServiceIdentity).toHaveBeenCalledWith({
scope,
confirmation,
}),
);
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeNull(),
);
expect(screen.getByText(/已安全恢复 1 个原生成 operation/)).toBeTruthy();
});
it('Tauri adapter 绑定 expectedProjectId,并在未知结果重试时复用 staging token', async () => {
const manifest = manifestFixture(scope.projectId);
let commitCalls = 0;
const invokeSpy = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'stage_local_project_asset_canvas_image') {
return {
status: 'staged',
stagedImageToken: 'stable-staging-token',
draftId: scope.draftId,
draftRevision: 0,
draft: null,
};
}
if (command === 'commit_local_project_asset') {
commitCalls += 1;
if (commitCalls === 1) throw new Error('IPC response lost');
return {
status: 'already-committed',
projectId: scope.projectId,
projectRevision: 1,
committedProjectRevision: 1,
draftId: scope.draftId,
draftRevision: 1,
commitId: '77777777-7777-4777-8777-777777777777',
idempotencyKey: '88888888-8888-4888-8888-888888888888',
eventId: 'stable-event',
asset: manifest.assets[0],
manifest,
};
}
if (
command === 'acknowledge_local_project_asset_canvas_candidate_layers'
) {
return { draft: draftFixture(scope) };
}
throw new Error(`unexpected command: ${command}`);
},
);
const adapter = createTauriImageCanvasHostAdapter({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
expectedHostRevision: 0,
invoke: invokeSpy as unknown as <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>,
});
const commitInput = {
scope,
expectedHostRevision: '0',
expectedDraftRevision: 0,
commitId: '77777777-7777-4777-8777-777777777777',
idempotencyKey: '88888888-8888-4888-8888-888888888888',
name: '幂等素材',
assetKind: 'illustration',
referenceResourceIds: [],
mediaType: 'image/png' as const,
bytes: Uint8Array.from([137, 80, 78, 71]),
};
expect((await adapter.completion.commitImage(commitInput)).status).toBe(
'failed',
);
const replay = await adapter.completion.commitImage(commitInput);
expect(replay.status).toBe('ok');
expect(
invokeSpy.mock.calls.filter(
([command]) => command === 'stage_local_project_asset_canvas_image',
),
).toHaveLength(1);
const commitRequests = invokeSpy.mock.calls.filter(
([command]) => command === 'commit_local_project_asset',
);
expect(commitRequests).toHaveLength(2);
expect(commitRequests[0]?.[1]).toEqual(commitRequests[1]?.[1]);
const acknowledgement = await adapter.project.acknowledgeCandidateLayers({
scope,
layerIds: ['candidate-layer'],
});
expect(acknowledgement.status).toBe('ok');
expect(invokeSpy).toHaveBeenCalledWith(
'acknowledge_local_project_asset_canvas_candidate_layers',
expect.objectContaining({
input: expect.objectContaining({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
draftId: scope.draftId,
layerIds: ['candidate-layer'],
}),
}),
);
const mismatch = await adapter.project.loadDraft({
...scope,
projectId: 'replacement-project',
});
expect(mismatch).toEqual(
expect.objectContaining({
status: 'failed',
code: 'project-identity-conflict',
}),
);
expect(invokeSpy).toHaveBeenCalledTimes(4);
const invalidRevision = await adapter.completion.commitImage({
...commitInput,
commitId: '99999999-9999-4999-8999-999999999999',
idempotencyKey: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
expectedHostRevision: '1e0',
});
expect(invalidRevision).toEqual(
expect.objectContaining({ status: 'failed' }),
);
expect(invokeSpy).toHaveBeenCalledTimes(4);
});
it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => {
const accessToken = 'session-token-must-stay-ephemeral';
setStoredAuthAccessToken(accessToken);
const manifest = manifestFixture(scope.projectId);
const invokeSpy = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'recover_local_project_asset_canvas_generations') {
return { resumedGenerationIds: [], serviceIdentityConfirmations: [] };
}
if (
command ===
'confirm_local_project_asset_canvas_generation_service_identity'
) {
return {
generationId: '22222222-2222-4222-8222-222222222222',
operationId: 'existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
identityScheme: 'service-origin-v1',
};
}
if (command !== 'generate_local_project_asset_canvas_image') {
throw new Error(`unexpected command: ${command}`);
}
return {
generation: {
generationId: '22222222-2222-4222-8222-222222222222',
intentId: '11111111-1111-4111-8111-111111111111',
phase: 'asset-durable-committed',
referenceResourceIds: [],
outputAssetId: 'canvas-commit',
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
images: [],
commit: {
resourceId: 'local-asset:canvas-commit',
assetId: 'canvas-commit',
projectId: scope.projectId,
commitId: '44444444-4444-4444-8444-444444444444',
committedProjectRevision: 1,
draftRevision: 1,
hostRevision: '1',
commitStatus: 'committed',
manifest,
eventId: 'event-token-test',
},
requestWasTransient: Boolean(args),
};
},
);
const adapter = createTauriImageCanvasHostAdapter({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
expectedHostRevision: 0,
invoke: invokeSpy as unknown as <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>,
});
const result = await adapter.generation.generateImage({
scope,
expectedHostRevision: '0',
expectedDraftRevision: 0,
intentId: '11111111-1111-4111-8111-111111111111',
generationId: '22222222-2222-4222-8222-222222222222',
idempotencyKey: '33333333-3333-4333-8333-333333333333',
commitId: '44444444-4444-4444-8444-444444444444',
commitIdempotencyKey: '55555555-5555-4555-8555-555555555555',
prompt: 'External 图片生成',
aspectRatio: '16:9',
imageSize: '1K',
assetKind: 'illustration',
assetName: 'External 素材',
referenceResourceIds: [],
});
expect(result.status).toBe('ok');
expect(invokeSpy).toHaveBeenCalledTimes(1);
const commandInput = invokeSpy.mock.calls[0]?.[1]?.input as Record<
string,
unknown
>;
expect(commandInput).not.toHaveProperty('accessToken');
expect(commandInput).not.toHaveProperty('apiKey');
expect(JSON.stringify(result)).not.toContain(accessToken);
clearStoredAuthAccessToken();
const recovery = await adapter.generation.recoverImages({ scope });
expect(recovery).toEqual({
status: 'ok',
value: { resumedGenerationIds: [], serviceIdentityConfirmations: [] },
});
const recoveryInput = invokeSpy.mock.calls[1]?.[1]?.input as Record<
string,
unknown
>;
expect(recoveryInput).not.toHaveProperty('accessToken');
expect(recoveryInput).not.toHaveProperty('apiKey');
expect(invokeSpy).toHaveBeenCalledTimes(2);
const confirmation = await adapter.confirmGenerationServiceIdentity({
scope,
confirmation: {
generationId: '22222222-2222-4222-8222-222222222222',
operationId: 'existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-value-that-is-long-enough-for-the-contract',
expiresAt: Date.now() + 60_000,
},
});
expect(confirmation.status).toBe('ok');
const confirmationInput = invokeSpy.mock.calls[2]?.[1]?.input as Record<
string,
unknown
>;
expect(confirmationInput).toEqual({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
draftId: scope.draftId,
generationId: '22222222-2222-4222-8222-222222222222',
operationId: 'existing-operation',
challenge: 'challenge-value-that-is-long-enough-for-the-contract',
});
expect(confirmationInput).not.toHaveProperty('accessToken');
expect(confirmationInput).not.toHaveProperty('apiKey');
expect(invokeSpy).toHaveBeenCalledTimes(3);
});
it('Tauri adapter 在 401 后刷新内存登录态并只重放原生成命令一次', async () => {
const accessToken = 'expired-platform-token';
setStoredAuthAccessToken(accessToken);
const installInvoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke: installInvoke } };
const authUser = {
id: 'user-test',
publicUserCode: 'tn-test',
displayName: '测试用户',
avatarUrl: null,
phoneNumber: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password' as const,
bindingStatus: 'active' as const,
wechatBound: false,
wechatDisplayName: null,
wechatAccount: null,
};
const generation = beginPlatformSessionTransition();
await commitAuthenticatedPlatformSession(authUser, generation);
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response(JSON.stringify({ token: 'refreshed-token' }), {
status: 200,
});
}
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: authUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
const manifest = manifestFixture(scope.projectId);
let generationCalls = 0;
const invokeSpy = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'generate_local_project_asset_canvas_image') {
throw new Error(`unexpected command: ${command}`);
}
generationCalls += 1;
if (generationCalls === 1) {
throw new Error('authentication-required: 登录已失效');
}
return {
generation: {
generationId: '22222222-2222-4222-8222-222222222222',
intentId: '11111111-1111-4111-8111-111111111111',
phase: 'asset-durable-committed',
referenceResourceIds: [],
outputAssetId: 'canvas-commit',
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
images: [],
commit: {
resourceId: 'local-asset:canvas-commit',
assetId: 'canvas-commit',
projectId: scope.projectId,
commitId: '44444444-4444-4444-8444-444444444444',
committedProjectRevision: 1,
draftRevision: 1,
hostRevision: '1',
commitStatus: 'committed',
manifest,
eventId: 'event-auth-refresh',
},
requestWasTransient: Boolean(args),
};
},
);
const adapter = createTauriImageCanvasHostAdapter({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
expectedHostRevision: 0,
invoke: invokeSpy as unknown as <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>,
});
const input = {
scope,
expectedHostRevision: '0',
expectedDraftRevision: 0,
intentId: '11111111-1111-4111-8111-111111111111',
generationId: '22222222-2222-4222-8222-222222222222',
idempotencyKey: '33333333-3333-4333-8333-333333333333',
commitId: '44444444-4444-4444-8444-444444444444',
commitIdempotencyKey: '55555555-5555-4555-8555-555555555555',
prompt: '生成主角',
aspectRatio: '1:1',
imageSize: '1K',
assetKind: 'illustration',
assetName: '主角',
referenceResourceIds: [],
};
await expect(adapter.generation.generateImage(input)).resolves.toEqual(
expect.objectContaining({ status: 'ok' }),
);
expect(invokeSpy).toHaveBeenCalledTimes(2);
expect(invokeSpy.mock.calls[0]?.[1]).toEqual(invokeSpy.mock.calls[1]?.[1]);
expect(installInvoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: authUser.id,
accessToken: 'refreshed-token',
}),
);
});
});