/** @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, openResourceSearch, 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)} /> ); } 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); }); it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); fireEvent.click(await screen.findByRole('button', { name: '生成素材' })); const panel = await screen.findByRole('dialog', { name: '生成素材' }); fireEvent.change(within(panel).getByLabelText('生成提示词'), { target: { value: '一段片头动画,镜头缓慢推进' }, }); fireEvent.click(within(panel).getByRole('button', { name: '生成视频' })); expect(await within(panel).findByRole('alert')).not.toBeNull(); fireEvent.click( within(panel).getByRole('button', { name: '使用原请求重试' }), ); await waitFor(() => expect(deriveCalls).toHaveLength(2)); const operationId = String(deriveCalls[0]?.operationId); expect(deriveCalls[0]).toMatchObject({ projectPath, expectedProjectId: 'live-canvas-project', editKind: 'video', generationMode: 'create', sourceResourceId: `create:${operationId}`, sourceAssetId: null, sourcePath: null, sourceMediaType: 'video/mp4', sourceSubtype: null, producerTaskId: null, sourceVersionId: null, prompt: '一段片头动画,镜头缓慢推进', assetName: '新视频', }); expect(deriveCalls[0]).not.toHaveProperty('accessToken'); expect(deriveCalls[1]?.operationId).toBe(operationId); expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey); expect(screen.queryByRole('dialog', { name: '生成素材' })).toBeNull(); // 产出物是新素材:画布定位并选中新卡片。 expect( (await findResourceSelectButton(`${operationId}-rules.md`)).getAttribute( 'aria-pressed', ), ).toBe('true'); }); 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 = openResourceSearch(); 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(openResourceSearch().value).toBe('source-art'); fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); // 清空只由这个显式动作发起:再叫出浮层读到的已经是空值。 expect(openResourceSearch().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 categoryTab = within(dialog).getByRole('button', { name: '角色与对象', }); expect(categoryTab.getAttribute('aria-pressed')).toBe('true'); fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' })); fireEvent.click(within(dialog).getByRole('button', { 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: [], }, }); // 关掉面板再看栏目(保存本身不关窗,关窗是头部 × 的职责)。 fireEvent.click( within(dialog).getByRole('button', { name: '关闭编辑素材标签' }), ); 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(); }); });