/** @vitest-environment jsdom */ import React, { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; const canvasFixture = vi.hoisted(() => ({ manifest: null as GameCreationAppManifest | null, revision: 0, })); import { calculateCharacterAnimationPrice, CHARACTER_ANIMATION_MODEL, } from '../../../src/components/image-editor/ImageCanvasGenerationModel'; import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences'; import ProjectDevelopmentView from '../src/view/project-development'; import { act, createGameCreationAppManifest, findResourceSelectButton, fireEvent, openResourceFilterPanel, queryResourceSelectButton, render, screen, setComposerText, waitFor, within, } from './appSurface/harness'; const projectPath = '/tmp/live-canvas-integration'; /** * 切栏目走「资源总览」的栏目缩略卡片(左侧大纲导航已删除)。 * 与两个 appSurface suite 里的同名助手同口径:切完必须落到目标栏目的子画布。 */ async function openResourceBookCategory(label: string) { const categoryByLabel: Record = { 'UI 交互': 'ui-interaction', 角色与对象: 'character', 场景与环境: 'scene', 音频: 'audio', 文档: 'document', 待归类: 'unclassified', 项目版本: 'version', }; if (document.querySelector('[data-resource-book-view="child"]')) { fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); } fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); await waitFor(() => { const manager = document.querySelector('[data-resource-book-view="child"]'); expect(manager).not.toBeNull(); expect( manager?.querySelector( `.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`, ), ).not.toBeNull(); }); } /** 资源卡右上角角标的文本(找不到卡片或角标时返回 null)。 */ async function cardBadgeText(fileName: string) { const select = await findResourceSelectButton(fileName); return ( select .closest('.game-resource-card') ?.querySelector('.game-resource-card-type-badge')?.textContent ?? null ); } /** * 总览态「所有资源」摞里那张预览卡的角标文本。 * * 覆盖用户看到的另一个面(总览缩略摞铺在上面的真实卡片本体),它与栏目卡共用 * `ResourceCard` 里那一处角标渲染,所以角标口径一处改、两处都跟着走。 */ function overviewStackBadgeText(fileName: string) { const cards = Array.from( document.querySelectorAll( '.game-resource-book-preview-card[data-resource-book-category="all"] .game-resource-card', ), ); const card = cards.find((entry) => entry .querySelector('.game-resource-card-select') ?.getAttribute('aria-label') ?.includes(fileName), ); return ( card?.querySelector('.game-resource-card-type-badge')?.textContent ?? null ); } function classificationWrites(invoke: ReturnType) { return invoke.mock.calls.filter( ([command]) => command === 'update_local_project_resource_classification', ); } type PendingResourceEditFixture = { operationId: string; editKind: string; sourceResourceId: string; assetName: string; phase: string; createdAt: number; }; function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, reject, resolve }; } function graphFor( resources: Array<{ resourceId: string }>, manifest: GameCreationAppManifest, ) { const resourceIds = resources.map((resource) => resource.resourceId); const referenceEdges = manifest.assets.flatMap((asset) => (asset.source.referenceResourceIds ?? []).map((sourceResourceId) => ({ id: `reference:${sourceResourceId}:${asset.id}`, kind: 'asset-reference' as const, sourceResourceId, targetResourceId: `asset:${asset.id}`, cyclic: false, })), ); return { resourceIds, referenceEdges, taskFlows: [], connectionIndex: resourceIds.map((resourceId) => ({ resourceId, upstreamReferenceResourceIds: referenceEdges .filter((edge) => edge.targetResourceId === resourceId) .map((edge) => edge.sourceResourceId), downstreamReferenceResourceIds: [], referenceEdgeIds: referenceEdges .filter( (edge) => edge.targetResourceId === resourceId || edge.sourceResourceId === resourceId, ) .map((edge) => edge.id), taskFlowIds: [], })), producerAssignments: [], dependencyDepths: resourceIds.map((resourceId) => ({ resourceId, dependencyDepth: referenceEdges.some( (edge) => edge.targetResourceId === resourceId, ) ? 1 : 0, })), unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], producerMappingTruncated: false, }; } function LiveWorkbench() { const initial = createGameCreationAppManifest( 'live-canvas-project', '实时画布项目', ); initial.assets = [ { id: 'source-art', kind: 'art-image', mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'canvas', taskId: null, resourceId: 'source-resource' }, }, ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; return ( Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} /> ); } /** * 素材类型用例的宿主:manifest 由状态持有、`onManifestChange` 真的回写, * 「改完类型后卡片进目标栏目、角标跟着变」走的才是真链路 * (命令 → 重读 manifest → 重投影 → 栏目分组 → 渲染),而不是断言桩。 */ function ClassificationWorkbench() { const initial = createGameCreationAppManifest( 'live-canvas-project', '实时画布项目', ); initial.assets = [ { id: 'asset-hero', kind: 'character', category: 'character', mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'generated', resourceId: 'hero-resource' }, }, { id: 'asset-ui', // `ui` 不在 canonical 目录里:`kind` 派生值经别名落到 `ui-interaction`, // 而落盘值仍是 `unclassified`。用来钉住"角标与栏目都走读显示口径"。 kind: 'ui', category: 'unclassified', mediaType: 'image/png', localPath: 'assets/panel.png', source: { kind: 'generated', resourceId: 'ui-resource' }, }, ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; return ( Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} /> ); } function DerivedWorkbench({ includeArt = false, includeCharacter = false, }: { includeArt?: boolean; includeCharacter?: boolean; }) { const initial = createGameCreationAppManifest( 'live-canvas-project', '实时画布项目', ); initial.assets = [ { id: 'source-rules', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, }, ...(includeArt ? [ { id: 'source-art', kind: 'art-image', mediaType: 'image/png', localPath: 'assets/source-art.png', source: { kind: 'generated', resourceId: 'art-resource' }, }, ] : []), ...(includeCharacter ? [ { id: 'source-character', kind: 'character', category: 'character' as const, mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'generated', resourceId: 'hero-resource' }, }, ] : []), ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; return ( Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} /> ); } type ExpandedSceneCard = { resourceId: string; categoryBadge: string; left: number; top: number; right: number; bottom: number; }; /** * 展开态画本里每张卡的世界矩形。 * * `--resource-x/y` 与卡片宽高就是渲染层按画本计划写上的几何(见资源卡的宿主样式), * 所以这里读到的是"卡片被画在哪儿",而不是测试自己重算的一份平行几何。 */ function readExpandedSceneCards(): ExpandedSceneCard[] { const hosts = Array.from( document.querySelectorAll( '[data-resource-book-view="child"] .game-resource-book-scene-world .game-resource-book-scene-card.is-expanded .game-resource-card', ), ); return hosts.flatMap((host) => { const resourceId = host.getAttribute('data-resource-card-id'); const style = (host as HTMLElement).style; const left = Number.parseFloat( style.getPropertyValue('--resource-x').replace('px', ''), ); const top = Number.parseFloat( style.getPropertyValue('--resource-y').replace('px', ''), ); const width = Number.parseFloat( style.getPropertyValue('--resource-card-width').replace('px', ''), ); const height = Number.parseFloat( style.getPropertyValue('--resource-card-height').replace('px', ''), ); if (!resourceId || !Number.isFinite(left + top + width + height)) { return []; } return [ { resourceId, categoryBadge: host.querySelector('.game-resource-card-type-badge')?.textContent ?? '', left, top, right: left + width, bottom: top + height, }, ]; }); } /** 逐对矩形相交检查:红的时候直接列出撞在一起的那两张卡的矩形。 */ function intersectingSceneCards(cards: readonly ExpandedSceneCard[]): string[] { const intersections: string[] = []; for (let left = 0; left < cards.length; left += 1) { for (let right = left + 1; right < cards.length; right += 1) { const a = cards[left]!; const b = cards[right]!; if ( a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom ) { intersections.push( `${a.resourceId}[${a.left},${a.top},${a.right},${a.bottom}] ∩ ` + `${b.resourceId}[${b.left},${b.top},${b.right},${b.bottom}]`, ); } } } return intersections; } /** * 文档 + 图片混排的宿主:6 份文档(`category: 'document'` → 文档栏)与 6 张图片 * (`kind: 'art-image'` → 待归类栏)。两种卡在依赖模式下都是 180 宽的卡,但落在两条 * 不同的栏目带上 —— 重叠只可能来自带几何,不可能是同一栏里的排布。 */ function MixedCategoryWorkbench() { const initial = createGameCreationAppManifest( 'live-canvas-project', '实时画布项目', ); initial.assets = [ ...Array.from({ length: 6 }, (_, index) => ({ id: `asset-doc-${index + 1}`, kind: 'document', category: 'document' as const, mediaType: 'text/markdown', localPath: `docs/note-0${index + 1}.md`, source: { kind: 'generated' as const, resourceId: `doc-resource-${index + 1}`, }, })), ...Array.from({ length: 6 }, (_, index) => ({ id: `asset-image-${index + 1}`, kind: 'art-image', mediaType: 'image/png', localPath: `assets/img-0${index + 1}.png`, source: { kind: 'generated' as const, resourceId: `image-resource-${index + 1}`, }, })), ]; const [manifest, setManifest] = useState(initial); canvasFixture.manifest = manifest; return ( Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} /> ); } function SwitchableDerivedWorkbench({ onManifestChange, }: { onManifestChange: (projectPath: string) => void; }) { const [project, setProject] = useState({ id: 'recovery-project-a', name: '恢复项目 A', path: '/tmp/recovery-project-a', }); const manifest = createGameCreationAppManifest(project.id, project.name); manifest.assets = [ { id: 'source-rules', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'docs/rules.md', source: { kind: 'generated', resourceId: 'rules-resource' }, }, ]; canvasFixture.manifest = manifest; return ( <> Supervisor} onHomeOpen={() => undefined} onProjectsOpen={() => undefined} onManifestChange={(changedProjectPath) => onManifestChange(changedProjectPath) } /> ); } describe('project resource live canvas integration', () => { afterEach(() => { delete window.__TAURI__; canvasFixture.manifest = null; canvasFixture.revision = 0; }); function installTauri( options: { failFirstDerive?: boolean; pendingResourceEdit?: boolean; pendingResourceEdits?: PendingResourceEditFixture[]; failFirstPendingRead?: boolean; readPendingResourceEdits?: ( input: Record, readCount: number, ) => PendingResourceEditFixture[] | Promise; resumeResourceEdit?: ( input: Record, ) => Promise>; requestResourceEditServiceIdentityConfirmation?: ( input: Record, ) => Promise>; confirmResourceEditServiceIdentity?: ( input: Record, ) => Promise>; /** 配了才应答 `polish_local_project_prompt`;不配时该命令直接抛错。 */ polishResult?: string; } = {}, ) { const layoutWrites: Array> = []; const graphReads: Array> = []; const deriveCalls: Array> = []; const polishCalls: Array> = []; const resumeCalls: Array> = []; const serviceIdentityRequestCalls: Array> = []; const serviceIdentityConfirmCalls: Array> = []; const archiveCalls: Array> = []; const pendingReadCalls: Array> = []; let pendingReadCount = 0; let pendingEdits = options.pendingResourceEdits ?? (options.pendingResourceEdit ? [ { operationId: '99999999-9999-4999-8999-999999999999', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '恢复的规则编辑版', phase: 'media-downloaded', createdAt: 1, }, ] : []); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_project_revision') { return { revision: canvasFixture.revision }; } if (command === 'read_local_project_resource_graph') { graphReads.push(structuredClone(args ?? {})); return graphFor( (args?.resources ?? []) as Array<{ resourceId: string }>, canvasFixture.manifest!, ); } if (command === 'read_local_project_resource_document') { return { path: String(args?.relativePath ?? 'docs/rules.md'), mediaType: 'text/markdown', byteLen: 1, content: '# rules', }; } if (command === 'read_local_project_resource_canvas_layout') { return { schemaVersion: 'game-creator-resource-layout.v1', projectId: 'live-canvas-project', mode: args?.mode, revision: 0, positions: [], updatedAt: 0, }; } if (command === 'update_local_project_resource_canvas_layout') { layoutWrites.push(structuredClone(args ?? {})); return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId: 'live-canvas-project', mode: args?.mode, revision: Number(args?.expectedRevision ?? 0) + 1, positions: args?.positions, updatedAt: 1, }, }; } if (command === 'read_local_project_image_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'image/png', byteLen: 1, dataUrl: 'data:image/png;base64,AA==', }; } if (command === 'read_local_project_text_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'text/markdown', byteLen: 8, content: '# 玩法规则', }; } if (command === 'list_pending_local_project_resource_edits') { const input = structuredClone( (args?.input ?? {}) as Record, ); pendingReadCount += 1; pendingReadCalls.push(input); if (options.failFirstPendingRead && pendingReadCount === 1) { throw new Error('恢复队列测试读取失败'); } if (options.readPendingResourceEdits) { return structuredClone( await options.readPendingResourceEdits(input, pendingReadCount), ); } return structuredClone(pendingEdits); } if (command === 'resume_local_project_resource_edit') { const input = structuredClone( (args?.input ?? {}) as Record, ); resumeCalls.push(input); if (options.resumeResourceEdit) { return await options.resumeResourceEdit(input); } const base = canvasFixture.manifest; if (!base) throw new Error('missing manifest fixture'); const operationId = String(input.operationId); pendingEdits = pendingEdits.filter( (pending) => pending.operationId !== operationId, ); const asset = { id: `edit-${operationId}`, kind: 'game-rules', mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-recovered-rules.md`, source: { kind: 'generated' as const, resourceId: `local-asset:edit-${operationId}`, referenceResourceIds: ['rules-resource'], }, }; canvasFixture.revision += 1; const nextManifest = { ...base, assets: [...base.assets, asset] }; canvasFixture.manifest = nextManifest; return { operationId, editKind: 'text', sourceResourceId: 'rules-resource', committedProjectRevision: canvasFixture.revision, asset, version: null, manifest: nextManifest, }; } if ( command === 'request_local_project_resource_edit_service_identity_confirmation' ) { const input = structuredClone( (args?.input ?? {}) as Record, ); serviceIdentityRequestCalls.push(input); if (options.requestResourceEditServiceIdentityConfirmation) { return await options.requestResourceEditServiceIdentityConfirmation( input, ); } throw new Error('unexpected resource edit service identity request'); } if ( command === 'confirm_local_project_resource_edit_service_identity' ) { const input = structuredClone( (args?.input ?? {}) as Record, ); serviceIdentityConfirmCalls.push(input); if (options.confirmResourceEditServiceIdentity) { return await options.confirmResourceEditServiceIdentity(input); } throw new Error('unexpected resource edit service identity confirm'); } if (command === 'archive_failed_local_project_resource_edit') { const input = structuredClone( (args?.input ?? {}) as Record, ); archiveCalls.push(input); pendingEdits = pendingEdits.filter( (pending) => pending.operationId !== input.operationId, ); return { operationId: input.operationId, phase: 'archived', }; } if (command === 'derive_local_project_resource') { const input = structuredClone( (args?.input ?? {}) as Record, ); deriveCalls.push(input); if (options.failFirstDerive && deriveCalls.length === 1) { throw new Error('result-unknown: 测试网络中断'); } const base = canvasFixture.manifest; if (!base) throw new Error('missing manifest fixture'); const operationId = String(input.operationId); const assetId = `edit-${operationId}`; const asset = { id: assetId, kind: 'game-rules', mediaType: 'text/markdown', localPath: `assets/edits/${operationId}-rules.md`, source: { kind: 'generated' as const, resourceId: `local-asset:${assetId}`, referenceResourceIds: ['rules-resource'], }, }; canvasFixture.revision += 1; const nextManifest = { ...base, assets: [...base.assets, asset], }; canvasFixture.manifest = nextManifest; return { operationId, editKind: input.editKind, sourceResourceId: 'rules-resource', committedProjectRevision: canvasFixture.revision, asset, version: null, manifest: nextManifest, }; } if (command === 'update_local_project_resource_classification') { const input = (args?.input ?? {}) as { assetId?: string; category?: string; tags?: string[]; }; const base = canvasFixture.manifest; if (!base) throw new Error('missing manifest fixture'); // 与 Rust `update_manifest_asset_classification_at` 同语义:只改目标条目的 // `category` / `tags`,并推进一次项目 revision。 canvasFixture.revision += 1; const nextManifest = { ...base, assets: base.assets.map((asset) => asset.id === input.assetId ? ({ ...asset, category: input.category, tags: input.tags ?? [], } as (typeof base.assets)[number]) : asset, ), }; canvasFixture.manifest = nextManifest; const asset = nextManifest.assets.find( (entry) => entry.id === input.assetId, ); if (!asset) throw new Error(`missing asset ${input.assetId}`); return { asset, committedProjectRevision: canvasFixture.revision, }; } if (command === 'get_local_game_manifest') { if (!canvasFixture.manifest) { throw new Error('missing manifest fixture'); } return canvasFixture.manifest; } if (command === 'polish_local_project_prompt') { polishCalls.push(structuredClone(args ?? {})); if (options.polishResult) { return options.polishResult; } throw new Error('polish unavailable in this fixture'); } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke }, event: { listen: async () => () => undefined }, }; return { invoke, deriveCalls, polishCalls, graphReads, layoutWrites, pendingReadCalls, resumeCalls, archiveCalls, serviceIdentityRequestCalls, serviceIdentityConfirmCalls, }; } it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); const panel = await screen.findByRole('dialog', { name: '快速编辑图片', }); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把角色头发设定改为红色', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); expect(await screen.findByRole('alert')).not.toBeNull(); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId); expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey); expect(deriveCalls[0]?.editKind).toBe('image-reference'); expect(deriveCalls[0]?.generationMode).toBe('derive'); expect(deriveCalls[0]).not.toHaveProperty('accessToken'); expect(deriveCalls[0]).not.toHaveProperty('apiKey'); expect(deriveCalls[1]).not.toHaveProperty('accessToken'); expect(deriveCalls[1]).not.toHaveProperty('apiKey'); }); it('从角色资源卡生成动画:走 character-animation 派生,且不渲染会被后端忽略的参数入口', async () => { const { deriveCalls } = installTauri(); render(); fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '生成动画' })); const panel = await screen.findByRole('dialog', { name: '角色动画生成面板', }); // 参数入口必须不存在:Rust 固定 720p / same / 32 帧 / 4 秒,改它不会改变请求。 expect( within(panel).queryByRole('button', { name: /动画参数/ }), ).toBeNull(); await setComposerText( within(panel).getByLabelText('动画描述'), '角色挥手打招呼', ); // 泥点价必须按 Rust 实际固定的 720p×4 秒算,而不是共享 draft 工厂的 480p 默认档: // 档位与 Rust 常量不一致,按钮上就是一个与实际计费不符的数字(内置价表 20 vs 10 每秒)。 const submitButton = within(panel).getByRole('button', { name: /生成[\d.]+泥点/, }); expect(submitButton.textContent).toContain( `${calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 4)}泥点`, ); expect(submitButton.textContent).not.toContain( `${calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '480p', 4)}泥点`, ); fireEvent.click(submitButton); await waitFor(() => expect(deriveCalls).toHaveLength(1)); expect(deriveCalls[0]?.editKind).toBe('character-animation'); expect(deriveCalls[0]?.generationMode).toBe('derive'); expect(deriveCalls[0]?.prompt).toBe('角色挥手打招呼'); expect(deriveCalls[0]?.assetName).toBe('hero-角色动画'); expect(deriveCalls[0]?.sourceAssetId).toBe('source-character'); expect(deriveCalls[0]?.sourceMediaType).toBe('image/png'); expect(deriveCalls[0]).not.toHaveProperty('accessToken'); expect(deriveCalls[0]).not.toHaveProperty('apiKey'); }); it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => { const { deriveCalls, polishCalls } = installTauri({ failFirstDerive: true, polishResult: '把角色头发设定改为亮红色', }); render(); fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); const panel = await screen.findByRole('dialog', { name: '快速编辑图片', }); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把角色头发改成红色', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); expect(await screen.findByRole('alert')).not.toBeNull(); // 失败后提示词仍可改:润色一次,回填的是润色结果,而不是原文。 fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' })); await waitFor(() => { expect( within(panel).getByLabelText('快速编辑提示词').textContent, ).toContain('亮红色'); }); expect(polishCalls[0]).toMatchObject({ prompt: '把角色头发改成红色' }); expect(String(polishCalls[0]?.context)).toContain( '图片素材的快速编辑提示词', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); // Rust 的 request_fingerprint 含 prompt:提示词换过就必须换 operationId / 幂等键, // 否则后端会判「已绑定到不同资源编辑请求」。 expect(deriveCalls[1]?.prompt).toBe('把角色头发设定改为亮红色'); expect(deriveCalls[1]?.operationId).not.toBe(deriveCalls[0]?.operationId); expect(deriveCalls[1]?.idempotencyKey).not.toBe( deriveCalls[0]?.idempotencyKey, ); }); it('快速编辑里润色失败:保留原文、给出可重试提示,也不换请求身份', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); const panel = await screen.findByRole('dialog', { name: '快速编辑图片', }); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把角色头发改成红色', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); expect(await screen.findByRole('alert')).not.toBeNull(); fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' })); expect( await within(panel).findByText('AI 润色失败,可重试'), ).not.toBeNull(); expect( within(panel).getByLabelText('快速编辑提示词').textContent, ).toContain('把角色头发改成红色'); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); // 提示词没变,身份也不该变:重试仍然命中同一 operation 账本。 expect(deriveCalls[1]?.operationId).toBe(deriveCalls[0]?.operationId); expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey); }); it('快速编辑提示词里能 @ 出资源选择器,插入的引用与聊天同字面量进入派生请求', async () => { const { deriveCalls } = installTauri(); render(); fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); const panel = await screen.findByRole('dialog', { name: '快速编辑图片', }); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把夜色改成星空', ); // 提示词输入区自带 `@` 入口:面板里的「插入素材引用」开出的是资源选择器本身, // 不是第二个面板或另一套引用格式。 fireEvent.click( within(panel).getByRole('button', { name: '插入素材引用' }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); fireEvent.click(within(picker).getByRole('option', { name: /^rules/u })); fireEvent.click(within(picker).getByRole('button', { name: '插入引用' })); // 回填:chip 与文本都落在快速编辑提示词里。 await waitFor(() => { expect( document.querySelector('[data-resource-reference-id="source-rules"]'), ).not.toBeNull(); }); expect( within(panel).getByLabelText('快速编辑提示词').textContent, ).toContain('把夜色改成星空@rules'); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(1)); // 出站负载:派生请求里的提示词就是聊天 `@` 拼出来的那一串(`@显示名`), // 期望值直接由共享引用模型算,钉住"快速编辑没有第二种引用格式"。 const mentionedAsset = canvasFixture.manifest?.assets.find( (entry) => entry.id === 'source-rules', ); if (!mentionedAsset) throw new Error('missing source-rules fixture'); const expectedText = `把夜色改成星空@${ resourceReferenceFromAsset(mentionedAsset, 'asset-picker').label }`; expect(deriveCalls[0]?.prompt).toBe(expectedText); }); /** * 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮, * 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。 * * 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡 * (React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的 * `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、 * 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。 */ it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => { installTauri(); render(); fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); const panel = await screen.findByRole('dialog', { name: '快速编辑图片', }); fireEvent.click( within(panel).getByRole('button', { name: '插入素材引用' }), ); const picker = await screen.findByRole('dialog', { name: '选择素材' }); // 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。 const manager = document.querySelector('.game-resource-book-manager'); expect(manager?.contains(picker)).toBe(false); expect(picker.parentElement).toBe(document.body); const list = picker.querySelector('.resource-reference-picker-list'); if (!list) throw new Error('missing picker list'); const overlayWheelCalls = vi.fn(); list.addEventListener('wheel', overlayWheelCalls); const readViewport = () => document .querySelector('[data-resource-viewport]') ?.getAttribute('data-resource-viewport'); const readSceneWorldTransform = () => document .querySelector('.game-resource-book-scene-world') ?.getAttribute('style'); const viewportBefore = readViewport(); const sceneWorldBefore = readSceneWorldTransform(); expect(viewportBefore).toBeTruthy(); // 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。 const overlayWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 240, clientX: 80, clientY: 60, }); act(() => { list.dispatchEvent(overlayWheel); }); // 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。 expect(overlayWheelCalls).toHaveBeenCalledTimes(1); expect(overlayWheel.defaultPrevented).toBe(false); // 画布视口一格不动。 expect(readViewport()).toBe(viewportBefore); expect(readSceneWorldTransform()).toBe(sceneWorldBefore); list.removeEventListener('wheel', overlayWheelCalls); // 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。 const sceneRoot = document.querySelector('.game-resource-book-scene'); if (!sceneRoot) throw new Error('missing scene root'); const canvasWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70, }); act(() => { sceneRoot.dispatchEvent(canvasWheel); }); expect(canvasWheel.defaultPrevented).toBe(true); expect(readViewport()).not.toBe(viewportBefore); }); it('does not expose a top-level canvas generation entry', async () => { installTauri(); render(); expect(screen.queryByRole('button', { name: '生成素材' })).toBeNull(); }); it('keeps the search condition, offers an explicit clear-and-locate, then locates the new asset', async () => { const { deriveCalls } = installTauri(); render(); fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); fireEvent.click(await findResourceSelectButton('source-art.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); const panel = await screen.findByRole('dialog', { name: '快速编辑图片', }); await setComposerText( within(panel).getByLabelText('快速编辑提示词'), '把角色头发设定改为红色', ); // 搜索条件在提交前就存在,新素材(文档分类)不会命中它:走"被当前搜索隐藏"分支。 const search = openResourceFilterPanel(); fireEvent.change(search, { target: { value: 'source-art' } }); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(1)); // 保留条件并明确提示,只通过显式动作清除条件并定位,不静默改搜索。 expect( await screen.findByText('新资源已保存,但被当前搜索条件隐藏'), ).not.toBeNull(); // 提交时点了快速编辑面板,搜索浮层按「点外部」收起;重新叫出来读到的仍是被保留的条件。 expect(openResourceFilterPanel().value).toBe('source-art'); fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); // 清空只由这个显式动作发起:再叫出浮层读到的已经是空值。 expect(openResourceFilterPanel().value).toBe(''); const operationId = String(deriveCalls[0]?.operationId); expect( (await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute( 'aria-pressed', ), ).toBe('true'); expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull(); }); it('hides the canvas generation entry when the client bridge is unavailable', async () => { render(); expect( await screen.findByRole('button', { name: '资源面板' }), ).not.toBeNull(); expect(screen.queryByRole('button', { name: '生成素材' })).toBeNull(); }); it('lists an unfinished edit and resumes it using only the private-ledger operation id', async () => { const { resumeCalls } = installTauri({ pendingResourceEdit: true }); render(); const manage = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); fireEvent.click(manage); const resume = await screen.findByRole('button', { name: '继续原编辑', }); fireEvent.click(resume); await waitFor(() => expect(resumeCalls).toHaveLength(1)); expect(resumeCalls[0]).toEqual({ projectPath, expectedProjectId: 'live-canvas-project', operationId: '99999999-9999-4999-8999-999999999999', }); expect(resumeCalls[0]).not.toHaveProperty('prompt'); expect(resumeCalls[0]).not.toHaveProperty('endpoint'); expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey'); // 恢复完成后新素材被选中;不再有"资源详情"面板。 expect( ( await screen.findByRole('button', { name: /选中资源:\S+ \S*recovered-rules\.md$/, }) ).getAttribute('aria-pressed'), ).toBe('true'); }); it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => { const operation: PendingResourceEditFixture = { operationId: 'abababab-abab-4bab-8bab-abababababab', editKind: 'video', sourceResourceId: 'source-video', assetName: '旧 Key 视频编辑', phase: 'accepted', createdAt: 1, }; let identityConfirmed = false; let committed = false; const { resumeCalls, serviceIdentityRequestCalls, serviceIdentityConfirmCalls, } = installTauri({ readPendingResourceEdits: () => (committed ? [] : [operation]), resumeResourceEdit: async () => { if (!identityConfirmed) { throw new Error( 'service-identity-confirmation-required: 当前服务地址需要用户确认', ); } const base = canvasFixture.manifest!; const asset = { id: `edit-${operation.operationId}`, kind: 'video', mediaType: 'video/mp4', localPath: `assets/edits/${operation.operationId}.mp4`, source: { kind: 'canvas' as const, resourceId: 'remote-video-resource', referenceResourceIds: ['source-video'], }, }; committed = true; canvasFixture.revision += 1; const manifest = { ...base, assets: [...base.assets, asset] }; canvasFixture.manifest = manifest; return { operationId: operation.operationId, editKind: 'video', sourceResourceId: 'source-video', committedProjectRevision: canvasFixture.revision, asset, version: null, manifest, }; }, requestResourceEditServiceIdentityConfirmation: async (input) => ({ operationId: input.operationId, remoteOperationId: 'remote-existing-operation', operationState: 'accepted', serviceOrigin: 'https://editor.example.test', challenge: 'challenge-bound-to-the-current-ledger-snapshot', expiresAt: 9_999_999_999, }), confirmResourceEditServiceIdentity: async (input) => { expect(input).toMatchObject({ operationId: operation.operationId, remoteOperationId: 'remote-existing-operation', challenge: 'challenge-bound-to-the-current-ledger-snapshot', }); identityConfirmed = true; return { operationId: operation.operationId, remoteOperationId: 'remote-existing-operation', operationState: 'accepted', serviceOrigin: 'https://editor.example.test', identityScheme: 'service-origin-v1', }; }, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); expect( await screen.findByText('当前服务:https://editor.example.test'), ).not.toBeNull(); expect(serviceIdentityRequestCalls).toHaveLength(1); expect(resumeCalls).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: '确认当前服务并继续' })); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(serviceIdentityConfirmCalls).toHaveLength(1); expect(resumeCalls[0]).toEqual(resumeCalls[1]); expect(resumeCalls[1]).toEqual({ projectPath, expectedProjectId: 'live-canvas-project', operationId: operation.operationId, }); }); it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => { const failedOperationId = '11111111-1111-4111-8111-111111111111'; const resumableOperationId = '22222222-2222-4222-8222-222222222222'; const reconciliationOperationId = '33333333-3333-4333-8333-333333333333'; const { archiveCalls, pendingReadCalls, resumeCalls } = installTauri({ pendingResourceEdits: [ { operationId: failedOperationId, editKind: 'image', sourceResourceId: 'source-art', assetName: '已失败图片编辑', phase: 'remote-failed', createdAt: 1, }, { operationId: resumableOperationId, editKind: 'text', sourceResourceId: 'rules-resource', assetName: '可继续规则编辑', phase: 'media-downloaded', createdAt: 2, }, { operationId: reconciliationOperationId, editKind: 'agent-result', sourceResourceId: 'agent-result', assetName: '需要对账的结果', phase: 'reconciliation-required', createdAt: 3, }, ], }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (3)', }), ); const dialog = await screen.findByRole('dialog', { name: '未完成的资源编辑', }); expect(dialog.textContent).toContain('远程明确失败'); expect(dialog.textContent).toContain('需要人工对账'); expect(screen.getByText('只读对账')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(resumeCalls).toHaveLength(1)); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect(resumeCalls[0]?.operationId).toBe(resumableOperationId); expect(resumeCalls[0]?.operationId).not.toBe(reconciliationOperationId); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)', }), ); fireEvent.click( await screen.findByRole('button', { name: '移出恢复队列' }), ); await waitFor(() => expect(archiveCalls).toHaveLength(1)); await waitFor(() => expect(pendingReadCalls).toHaveLength(3)); expect(archiveCalls[0]?.operationId).toBe(failedOperationId); expect(screen.getByText('只读对账')).not.toBeNull(); expect( await screen.findByRole('button', { name: '管理未完成编辑 (1)', }), ).not.toBeNull(); }); it('项目切换后丢弃旧项目迟到的恢复队列读取结果', async () => { const oldRead = createDeferred(); const oldOperation: PendingResourceEditFixture = { operationId: '44444444-4444-4444-8444-444444444444', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '旧项目迟到任务', phase: 'media-downloaded', createdAt: 1, }; const newOperation: PendingResourceEditFixture = { operationId: '55555555-5555-4555-8555-555555555555', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '新项目权威任务', phase: 'media-downloaded', createdAt: 2, }; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (input) => input.projectPath === '/tmp/recovery-project-a' ? oldRead.promise : [newOperation], }); render( undefined} />); await waitFor(() => expect(pendingReadCalls).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' })); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect(pendingReadCalls[1]).toMatchObject({ projectPath: '/tmp/recovery-project-b', expectedProjectId: 'recovery-project-b', }); expect( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ).not.toBeNull(); await act(async () => { oldRead.resolve([oldOperation]); await oldRead.promise; }); fireEvent.click(screen.getByRole('button', { name: '管理未完成编辑 (1)' })); expect(await screen.findByText('新项目权威任务')).not.toBeNull(); expect(screen.queryByText('旧项目迟到任务')).toBeNull(); }); it('项目切换后丢弃旧项目迟到的恢复结果', async () => { const staleResume = createDeferred>(); const staleOperation: PendingResourceEditFixture = { operationId: '66666666-6666-4666-8666-666666666666', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '旧项目恢复任务', phase: 'media-downloaded', createdAt: 1, }; const currentOperation: PendingResourceEditFixture = { operationId: '77777777-7777-4777-8777-777777777777', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '新项目保留任务', phase: 'media-downloaded', createdAt: 2, }; const onManifestChange = vi.fn(); const { resumeCalls } = installTauri({ readPendingResourceEdits: (input) => input.projectPath === '/tmp/recovery-project-a' ? [staleOperation] : [currentOperation], resumeResourceEdit: () => staleResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)', }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(resumeCalls).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' })); const currentTrigger = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); expect(screen.queryByText('旧项目恢复任务')).toBeNull(); const staleManifest = createGameCreationAppManifest( 'recovery-project-a', '恢复项目 A', ); const staleAsset = { id: 'stale-recovered-asset', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'assets/stale-recovered-rules.md', source: { kind: 'generated' as const, resourceId: 'local-asset:stale-recovered-asset', referenceResourceIds: ['rules-resource'], }, }; staleManifest.assets = [...staleManifest.assets, staleAsset]; await act(async () => { staleResume.resolve({ operationId: staleOperation.operationId, editKind: 'text', sourceResourceId: 'rules-resource', committedProjectRevision: 1, asset: staleAsset, version: null, manifest: staleManifest, }); await staleResume.promise; }); expect(onManifestChange).not.toHaveBeenCalled(); fireEvent.click(currentTrigger); expect(await screen.findByText('新项目保留任务')).not.toBeNull(); expect(screen.queryByText('旧项目恢复任务')).toBeNull(); expect( screen.queryByRole('button', { name: /stale-recovered/u }), ).toBeNull(); }); it('恢复成功后的权威队列重读迟到时不会关闭新项目面板', async () => { const postActionRead = createDeferred(); const oldOperation: PendingResourceEditFixture = { operationId: '88888888-8888-4888-8888-888888888888', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '旧项目已恢复任务', phase: 'media-downloaded', createdAt: 1, }; const newOperation: PendingResourceEditFixture = { operationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '新项目不能被关闭的任务', phase: 'media-downloaded', createdAt: 2, }; const { pendingReadCalls, resumeCalls } = installTauri({ readPendingResourceEdits: (input, readCount) => { if (input.projectPath === '/tmp/recovery-project-b') { return [newOperation]; } return readCount === 1 ? [oldOperation] : postActionRead.promise; }, }); render( undefined} />); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(resumeCalls).toHaveLength(1)); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); fireEvent.click(screen.getByRole('button', { name: '切换到恢复项目 B' })); await waitFor(() => expect(pendingReadCalls).toHaveLength(3)); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); expect(await screen.findByText('新项目不能被关闭的任务')).not.toBeNull(); await act(async () => { postActionRead.resolve([]); await postActionRead.promise; }); expect( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); expect(screen.getByText('新项目不能被关闭的任务')).not.toBeNull(); }); it('恢复失败后重读权威队列并展示后端迁移后的失败阶段', async () => { const operation: PendingResourceEditFixture = { operationId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '阶段迁移任务', phase: 'media-downloaded', createdAt: 1, }; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (_input, readCount) => [ { ...operation, phase: readCount === 1 ? 'media-downloaded' : 'remote-failed', }, ], resumeResourceEdit: async () => { throw new Error('远程恢复明确失败'); }, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect((await screen.findByRole('alert')).textContent).toContain( '远程恢复明确失败', ); expect(screen.getByText('远程明确失败')).not.toBeNull(); expect(screen.getByRole('button', { name: '移出恢复队列' })).not.toBeNull(); }); it('仅锁定正在操作的 operation,同项防双击且其他项可并行', async () => { const firstResume = createDeferred>(); const secondResume = createDeferred>(); const firstOperationId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const secondOperationId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const { resumeCalls } = installTauri({ pendingResourceEdits: [ { operationId: firstOperationId, editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行任务一', phase: 'media-downloaded', createdAt: 1, }, { operationId: secondOperationId, editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行任务二', phase: 'media-downloaded', createdAt: 2, }, ], resumeResourceEdit: (input) => input.operationId === firstOperationId ? firstResume.promise : secondResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)' }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); fireEvent.click(actions[0]); fireEvent.click(actions[0]); expect(actions[0].hasAttribute('disabled')).toBe(true); expect(actions[1].hasAttribute('disabled')).toBe(false); fireEvent.click(actions[1]); await waitFor(() => expect(resumeCalls).toHaveLength(2)); expect(resumeCalls.map((call) => call.operationId)).toEqual([ firstOperationId, secondOperationId, ]); }); it('并行 operation 的权威重读互相判旧时仍分别保留原始失败', async () => { const firstResume = createDeferred>(); const secondResume = createDeferred>(); const firstRead = createDeferred(); const secondRead = createDeferred(); const firstOperation: PendingResourceEditFixture = { operationId: 'f1111111-1111-4111-8111-111111111111', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行失败任务一', phase: 'media-downloaded', createdAt: 1, }; const secondOperation: PendingResourceEditFixture = { operationId: 'f2222222-2222-4222-8222-222222222222', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '并行失败任务二', phase: 'media-downloaded', createdAt: 2, }; const operations = [firstOperation, secondOperation]; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (_input, readCount) => { if (readCount === 1) return operations; return readCount === 2 ? firstRead.promise : secondRead.promise; }, resumeResourceEdit: (input) => input.operationId === firstOperation.operationId ? firstResume.promise : secondResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (2)', }), ); const actions = screen.getAllByRole('button', { name: '继续原编辑' }); fireEvent.click(actions[0]); fireEvent.click(actions[1]); await act(async () => { firstResume.reject(new Error('并行恢复一失败')); await firstResume.promise.catch(() => undefined); }); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); await act(async () => { secondResume.reject(new Error('并行恢复二失败')); await secondResume.promise.catch(() => undefined); }); await waitFor(() => expect(pendingReadCalls).toHaveLength(3)); await act(async () => { firstRead.resolve(operations); await firstRead.promise; }); expect((await screen.findByRole('alert')).textContent).toContain( '并行恢复一失败', ); await act(async () => { secondRead.resolve(operations); await secondRead.promise; }); await waitFor(() => { const alert = screen.getByRole('alert'); expect(alert.textContent).toContain('并行恢复一失败'); expect(alert.textContent).toContain('并行恢复二失败'); }); }); it('旧弹窗动作完成时不会关闭用户后来重新打开的同项目弹窗', async () => { const pendingResume = createDeferred>(); const operation: PendingResourceEditFixture = { operationId: 'f3333333-3333-4333-8333-333333333333', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '跨弹窗恢复任务', phase: 'media-downloaded', createdAt: 1, }; const { pendingReadCalls } = installTauri({ readPendingResourceEdits: (_input, readCount) => readCount === 1 ? [operation] : [], resumeResourceEdit: () => pendingResume.promise, }); render(); const trigger = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); fireEvent.click(trigger); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); fireEvent.click( screen.getByRole('button', { name: '关闭资源编辑恢复面板' }), ); await waitFor(() => expect( screen.queryByRole('dialog', { name: '未完成的资源编辑' }), ).toBeNull(), ); fireEvent.click(trigger); expect( await screen.findByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); const base = canvasFixture.manifest!; const asset = { id: 'reopened-dialog-output', kind: 'game-rules', mediaType: 'text/markdown', localPath: 'assets/reopened-dialog-output.md', source: { kind: 'generated' as const, resourceId: 'local-asset:reopened-dialog-output', referenceResourceIds: ['rules-resource'], }, }; const nextManifest = { ...base, assets: [...base.assets, asset] }; canvasFixture.manifest = nextManifest; await act(async () => { pendingResume.resolve({ operationId: operation.operationId, editKind: 'text', sourceResourceId: 'rules-resource', committedProjectRevision: 1, asset, version: null, manifest: nextManifest, }); await pendingResume.promise; }); await waitFor(() => expect(pendingReadCalls).toHaveLength(2)); expect( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); expect(screen.getByText('当前没有未完成的资源编辑')).not.toBeNull(); }); it('恢复已提交但权威队列重读失败时保留面板并显式报错', async () => { const operation: PendingResourceEditFixture = { operationId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', editKind: 'text', sourceResourceId: 'rules-resource', assetName: '已提交待确认任务', phase: 'media-downloaded', createdAt: 1, }; installTauri({ readPendingResourceEdits: (_input, readCount) => { if (readCount > 1) throw new Error('提交后权威队列读取失败'); return [operation]; }, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), ); fireEvent.click(screen.getByRole('button', { name: '继续原编辑' })); expect((await screen.findByRole('alert')).textContent).toContain( '提交后权威队列读取失败', ); expect( screen.getByRole('dialog', { name: '未完成的资源编辑' }), ).not.toBeNull(); expect(screen.getByText('已提交待确认任务')).not.toBeNull(); }); it('恢复队列读取失败时显式提供重试,不伪装成空队列', async () => { installTauri({ failFirstPendingRead: true, pendingResourceEdit: true, }); render(); fireEvent.click( await screen.findByRole('button', { name: '恢复队列读取失败' }), ); expect((await screen.findByRole('alert')).textContent).toContain( '恢复队列测试读取失败', ); fireEvent.click(screen.getByRole('button', { name: '重试读取' })); expect(await screen.findByText('恢复的规则编辑版')).not.toBeNull(); }); it('恢复面板循环约束 Tab 焦点,Escape 关闭后归还触发器聚焦', async () => { installTauri({ pendingResourceEdit: true }); render(); const trigger = await screen.findByRole('button', { name: '管理未完成编辑 (1)', }); fireEvent.click(trigger); const close = await screen.findByRole('button', { name: '关闭资源编辑恢复面板', }); expect(document.activeElement).toBe(close); const resume = screen.getByRole('button', { name: '继续原编辑' }); fireEvent.keyDown(window, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(resume); fireEvent.keyDown(window, { key: 'Tab' }); expect(document.activeElement).toBe(close); trigger.focus(); fireEvent.keyDown(window, { key: 'Tab' }); expect(document.activeElement).toBe(close); fireEvent.keyDown(window, { key: 'Escape' }); await waitFor(() => expect( screen.queryByRole('dialog', { name: '未完成的资源编辑' }), ).toBeNull(), ); await waitFor(() => expect(document.activeElement).toBe(trigger)); }); it('恢复动作使当前按钮动态禁用后仍将 Tab 焦点约束在弹窗内', async () => { const pendingResume = createDeferred>(); installTauri({ pendingResourceEdit: true, resumeResourceEdit: () => pendingResume.promise, }); render(); fireEvent.click( await screen.findByRole('button', { name: '管理未完成编辑 (1)', }), ); const close = await screen.findByRole('button', { name: '关闭资源编辑恢复面板', }); const resume = screen.getByRole('button', { name: '继续原编辑' }); resume.focus(); fireEvent.click(resume); await waitFor(() => expect(resume.hasAttribute('disabled')).toBe(true)); expect(document.activeElement).toBe(resume); fireEvent.keyDown(window, { key: 'Tab' }); expect(document.activeElement).toBe(close); await act(async () => { pendingResume.reject(new Error('焦点测试恢复失败')); await pendingResume.promise.catch(() => undefined); }); expect((await screen.findByRole('alert')).textContent).toContain( '焦点测试恢复失败', ); }); /** * 卡片右上角角标显示的是**资源类型**(功能分类中文名),等于这张卡所在的画布栏目。 * * 两条判据缺一不可: * - `asset-hero`(落盘 `character`):角标「角色与对象」,不可能是媒体类型「图片」; * - `asset-ui`(落盘 `unclassified` + `kind:"ui"`):读显示口径自愈成 `ui-interaction`, * 所以角标与栏目都是「UI 交互」——不是落盘值推出的「待归类」。 * * 变异验证:把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`), * 两条都会变红(分别是「图片」与「图片」)。 */ it('角标显示资源类型(与所在栏目同名),落盘 unclassified 的 UI 资产按自愈口径显示', async () => { installTauri(); render(); await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); await openResourceBookCategory('UI 交互'); expect(await cardBadgeText('panel.png')).toBe('UI 交互'); }); /** * 用户改素材类型后的**整条跟随链**(真宿主:命令 → 重读 manifest → 重投影 → 栏目分组): * 1. 总览「所有资源」摞里那张预览卡的角标跟着变; * 2. 卡片挪到新类型对应的栏目,角标等于新栏目名; * 3. 原栏目里不再有它。 * * 变异验证(已实测): * - 把写入改回"永远回传落盘原值" → 载荷 category 不是 `scene`,第一步就红; * - 把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`)→ 角标恒为「图片」,红。 * * 已实测**不可构造**的变异:「角标只在挂载时算一次」(`useState(() => …)`)在本用例下仍然绿。 * 原因是选中一张资源卡本身就会把画布切进它所在栏目的子画布,而改类型又让这张卡换到另一个 * 栏目的分组里 —— React 每次都会卸载重建卡片宿主,因此挂载快照与实时读数在 DOM 上同形。 * 换句话说:这条链路在当前结构下不存在"卡片留在原地、值却过期"的窗口, * 跟随用例能钉住的是**取值来源**(分类 vs 媒体类型),不是缓存时机。 */ it('改素材类型后卡片进目标栏目、角标跟着变', async () => { const { invoke } = installTauri(); render(); await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); // 面板里的类型单选列表显示的就是卡片当前所在栏目(纵向列表:一行一个选项)。 const categoryOption = within(dialog).getByRole('radio', { name: '角色与对象', }); expect(categoryOption.getAttribute('aria-checked')).toBe('true'); // 「选中即落盘」:这一次点击本身就是完整动作,不需要任何标签动作。 fireEvent.click(within(dialog).getByRole('radio', { name: '场景与环境' })); await waitFor(() => { expect(classificationWrites(invoke)).toHaveLength(1); }); const [, args] = classificationWrites(invoke)[0]!; expect(args).toEqual({ input: { projectPath, expectedProjectId: 'live-canvas-project', expectedProjectRevision: expect.any(Number), assetId: 'asset-hero', category: 'scene', tags: [], }, }); // 保存成功后由宿主收起面板(关窗不是用户的第二个动作):用户马上就能在画布上 // 看到卡片落进新栏目,而不是隔着一块挡画布的浮层去猜。 await waitFor(() => expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(), ); // 跟随一:总览「所有资源」摞里那张预览卡的角标**就地**跟着变(同一个卡片宿主, // 不是卸载重建)—— 这一条才钉得住"角标取值来自当前投影、不是挂载时算一次的旧值"。 fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); expect(overviewStackBadgeText('hero.png')).toBe('场景与环境'); // 跟随二:卡片出现在「场景与环境」栏,且角标同步成新类型名。 await openResourceBookCategory('场景与环境'); const select = await findResourceSelectButton('hero.png'); expect( select .closest('[data-resource-book-category]') ?.getAttribute('data-resource-book-category'), ).toBe('scene'); expect(await cardBadgeText('hero.png')).toBe('场景与环境'); // 跟随三:原栏目里没有它了。 await openResourceBookCategory('角色与对象'); expect(queryResourceSelectButton('hero.png')).toBeNull(); }); /** * 两块分类浮层的关闭时机互不串台:类型面板与标签面板都是 portal 到 body 的模态浮层, * 开着时「点外部清画布焦点」与画布自己的 Esc 都必须让位 —— * 关掉的只能是浮层本身,画布选中与工具条都得留着。 * * 变异验证(已实测):把 `resourceTypeAssetId` 从 `isClassificationPanelOpen` 判据里去掉 * (只留标签面板那一半),Esc 那一步会被画布的 Esc 抢走:类型面板关不掉、工具条一起消失, * 本用例必须失败。 */ it('类型面板与标签面板:Esc / 点外部都只关浮层,不动画布选中', async () => { const { invoke } = installTauri(); render(); await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); // Esc 只关类型面板,选中(工具条)留着 —— 用户接着还能做别的动作。 fireEvent.keyDown(document.body, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(), ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); // 标签面板走同一份判据:Esc 关面板,不清选中。 fireEvent.click( within(screen.getByRole('toolbar', { name: '图片工具栏' })).getByRole( 'button', { name: '编辑标签' }, ), ); await screen.findByRole('dialog', { name: '编辑素材标签' }); fireEvent.keyDown(document.body, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(), ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); // 收尾自检:没有写入发生 —— 这两个动作都不该改数据。 expect(classificationWrites(invoke)).toHaveLength(0); }); /** * 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + `kind:"ui"` 的资产 * 在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,manifest 上的 `category` * 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时出现过两种落盘值。 * * 变异验证(已实测):把标签面板的载荷改回读显示口径 * (`gameCreationAppAssetCategory(asset)`),manifest 上的值会变成 `ui-interaction`, * 本用例必须失败。 */ it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified + kind ui 的资产)', async () => { const { invoke } = installTauri(); render(); await openResourceBookCategory('UI 交互'); fireEvent.click(await findResourceSelectButton('panel.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' }); fireEvent.change( within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), { target: { value: '界面' } }, ); fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1)); const persisted = canvasFixture.manifest?.assets.find( (entry) => entry.id === 'asset-ui', ); // 落盘原值逐字不变(不是自愈出来的 ui-interaction),标签写进去了。 expect(persisted?.category).toBe('unclassified'); expect(persisted?.tags).toEqual(['界面']); }); /** * 第二入口:信息浮层的「分类」行点进「设置素材类型」。 * * 信息浮层是只读事实卡,新增的可点字段只有「分类」——点它等同于点工具条的「素材类型」: * 同一块面板、同一条写入命令,不另开第二条写路径。 * * 变异验证:浮层不传 `onEditCategory`(或按钮渲染进 `dd`),本用例必须失败。 */ it('信息浮层的「分类」行可以进类型设置,落盘走同一条链路', async () => { const { invoke } = installTauri(); render(); await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 expect( Array.from(infoPanel.querySelectorAll('dt')).map( (node) => node.textContent, ), ).toContain('分类'); expect( Array.from(infoPanel.querySelectorAll('dd')).map( (node) => node.textContent, ), ).toContain('角色与对象'); fireEvent.click( within(infoPanel).getByRole('button', { name: '设置素材类型' }), ); // 信息浮层先收起(它锚在卡片位置,而卡片马上要换栏目),类型面板接着打开。 expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); const dialog = await screen.findByRole('dialog', { name: '设置素材类型' }); expect( within(dialog) .getByRole('radio', { name: '角色与对象' }) .getAttribute('aria-checked'), ).toBe('true'); fireEvent.click(within(dialog).getByRole('radio', { name: '音频' })); await waitFor(() => { expect(classificationWrites(invoke)).toHaveLength(1); }); expect(classificationWrites(invoke)[0]?.[1]).toEqual({ input: { projectPath, expectedProjectId: 'live-canvas-project', expectedProjectRevision: expect.any(Number), assetId: 'asset-hero', category: 'audio', tags: [], }, }); await waitFor(() => expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(), ); }); /** * 用户报的原始现象(PR #316 反馈):在「所有资源」一栏里,文档卡与图片卡**一开始**就自动 * 重叠 —— 文档那一排的第 2、3 行被图片栏的卡片整排压住。 * * 真宿主链路:依赖排序下,关系图读回之前布局 hook 给的是"没有坐标"的布局;展开态的分带 * 几何当时按"所有卡都在原点"算出来(文档带只有单行 144 高),坐标到位后若带几何不重算, * 图片栏就仍被摆在 144 + 48 处,而文档卡已经铺到 y = 168 / 336。 * * 判据取渲染出来的卡片世界矩形(`--resource-x/y` 与卡片尺寸就是画本按计划写上的几何), * 逐对做矩形相交检查:jsdom 没有排版引擎,这是"画出来的位置"在测试环境里能拿到的最强证据。 */ it('「所有资源」展开态:文档卡与图片卡初始自动布局逐对不相交', async () => { installTauri(); render(); // 依赖排序:坐标等关系图读回(这就是用户看到的"一开始")。 fireEvent.click(await screen.findByRole('button', { name: '按依赖' })); fireEvent.click( await screen.findByRole('button', { name: '打开所有资源' }), ); const cards = await waitFor(() => { const rendered = readExpandedSceneCards(); expect(rendered.length).toBe(12); return rendered; }); // 前提自检:两个栏目都真的有卡(不是"只有一种类型的卡"导致用例空转)。 expect(new Set(cards.map((card) => card.categoryBadge))).toEqual( new Set(['文档', '待归类']), ); const documentTops = new Set( cards .filter((card) => card.categoryBadge === '文档') .map((card) => card.top), ); // 文档栏至少两行:单行时"带高压住下一带"的现象根本构造不出来,用例会失去意义。 expect(documentTops.size).toBeGreaterThan(1); expect(intersectingSceneCards(cards)).toEqual([]); }); });