// @vitest-environment jsdom import { cleanup, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { $getRoot } from 'lexical'; import { StrictMode, useState } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { GameCreationAppAssetManifestEntry, GameIterationVersion, } from '../../../packages/shared/src/contracts/gameCreationApp'; import { LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, parseLocalGamePreviewInspectMessage, } from '../src/features/project-workspace/LocalGamePreviewFrame'; import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, type ChatReference, chatReferenceListKey, currentIterationVersionAssets, dedupeChatReferences, dispatchResourceReferenceInsert, resolveActiveIterationVersion, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_INSERT_EVENT, RESOURCE_REFERENCE_SCOPES, resourceReferenceCategory, resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, resourceReferenceMatchesTagSelection, resourceReferenceTagLibrary, type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; function asset( id: string, kind: string, mediaType: string, localPath: string, ): GameCreationAppAssetManifestEntry { return { id, kind, mediaType, localPath, source: { kind: 'uploaded' }, }; } function iterationVersion( versionId: string, resourceIds: string[], parentVersionId: string | null = null, ): GameIterationVersion { return { versionId, parentVersionId, projectRevision: 1, resourceBindings: resourceIds.map((resourceId, index) => ({ slotId: `slot-${index}`, resourceId, })), createdReason: 'initial', createdAt: 1, }; } async function settleComposer() { await new Promise((resolve) => { setTimeout(resolve, 0); }); } /** * 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本: * DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。 */ function composerEditor(): { getEditorState: () => { read: (fn: () => T) => T }; } { const element = screen.getByLabelText('聊天') as HTMLElement & { __lexicalEditor?: { getEditorState: () => { read: (fn: () => T) => T }; }; }; const editor = element.__lexicalEditor; if (!editor) throw new Error('lexical editor instance not found'); return editor; } function editorTextSize() { return composerEditor() .getEditorState() .read(() => $getRoot().getTextContentSize()); } const assets = [ asset('hero', 'character', 'image/png', 'assets/hero.png'), asset('enemy', 'character', 'image/png', 'assets/enemy.png'), asset('theme', 'background-music', 'audio/mpeg', 'assets/theme.mp3'), ]; /** 带 manifest 标签的同一批素材:标签筛选用例只换 `tags`,其余口径与 `assets` 一致。 */ const taggedAssets: GameCreationAppAssetManifestEntry[] = [ { ...assets[0]!, tags: ['角色', '主角'] }, { ...assets[1]!, tags: ['角色'] }, { ...assets[2]!, tags: ['背景乐'] }, ]; /** * 面板里的候选筛选条由共享组件渲染,标签行是它内部带 `素材筛选标签` 名字的分组。 * 用这一层 scope 取标签 chip,避免和同名的功能分类按钮(例如「角色与对象」)串。 */ function pickerTagGroup() { return within(screen.getByRole('group', { name: '素材筛选标签' })); } /** 取某个 aria-label 的引用输入区,把它内部(含 `@` 按钮)的操作局限定在自己身上。 */ function referenceInputByLabel(ariaLabel: string) { const editor = screen.getByLabelText(ariaLabel); const root = editor.closest('.resource-reference-input'); if (!root) throw new Error(`resource reference input not found: ${ariaLabel}`); return within(root as HTMLElement); } async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) { await userEvent.click( referenceInputByLabel(ariaLabel).getByRole('button', { name: '插入素材引用', }), ); await userEvent.click( within(screen.getByRole('dialog', { name: '选择素材' })).getByRole( 'option', { name: optionName }, ), ); await userEvent.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); } afterEach(cleanup); describe('ResourceReferenceInput', () => { test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => { const base: RuntimeRegionReference = { type: 'runtime-region', label: 'Play', runId: 'run-1', versionId: 'v1', elementTag: 'button', elementRole: 'button', text: '开始', width: 120, height: 40, resourceIds: ['hero', 'enemy'], source: 'runtime-picker', }; // 同一个 run 里两个同文案的元素、或重新选点后绑定变了的同一块区域,都是不同引用: // 指纹漏字段会让 `dedupeChatReferences` 静默丢掉其中一条。 const distinctVariants: RuntimeRegionReference[] = [ { ...base, resourceIds: ['hero'] }, { ...base, versionId: 'v2' }, { ...base, elementRole: 'link' }, { ...base, width: 200 }, { ...base, height: 80 }, ]; for (const variant of distinctVariants) { expect(chatReferenceListKey([variant])).not.toBe( chatReferenceListKey([base]), ); expect(dedupeChatReferences([base, variant])).toHaveLength(2); } // 绑定素材顺序不影响身份:同一组素材换个顺序还是同一条引用。 expect( dedupeChatReferences([base, { ...base, resourceIds: ['enemy', 'hero'] }]), ).toHaveLength(1); }); test('AI 润色只换文本时不会把引用一轮轮追加成重复 @显示名', async () => { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { const [draft, setDraft] = useState({ text: '原始需求', references: [reference], }); return ( <> { onChange(next); setDraft(next); }} assets={assets} projectPath="C:/project" ariaLabel="聊天" /> ); } render(); await settleComposer(); fireEvent.click(screen.getByRole('button', { name: '模拟润色' })); await settleComposer(); await settleComposer(); // 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主: // 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名` // 会触发下一轮重建,文本一轮轮变长(渲染循环)。 expect(onChange).not.toHaveBeenCalled(); const editorText = document.querySelector('.resource-reference-input-editor')?.textContent ?? ''; expect(editorText).toContain('润色后的需求'); expect(editorText.match(/@hero/gu) ?? []).toHaveLength(1); expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); function Controlled() { const [draft, setDraft] = useState({ text: '要一个', references: [], }); return (
{ event.preventDefault(); onSubmit(); }} > ); } render(); // 候选浮层开着时 Enter 属于浮层(候选菜单用它选引用),输入区的 HIGH 优先级提交 // 必须先让位,否则用户按 Enter 选候选会变成把这一轮直接发出去。 await user.click(screen.getByRole('button', { name: '插入素材引用' })); await screen.findByRole('dialog', { name: '选择素材' }); const composer = screen.getByLabelText('聊天'); expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false); expect(onSubmit).not.toHaveBeenCalled(); // 对照:关掉浮层后 Enter 立刻恢复提交语义,说明上面不是编辑器整体坏掉。 await user.click(screen.getByRole('button', { name: '关闭素材选择' })); expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false); await waitFor(() => { expect(onSubmit).toHaveBeenCalledTimes(1); }); }); test('opens the asset picker, supports multi-select, and inserts stable references', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect(screen.getByRole('dialog', { name: '选择素材' })).not.toBeNull(); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('option', { name: /enemy/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await waitFor(() => { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; expect(draft?.text).toBe('@hero @enemy'); expect(draft?.references.map((reference) => reference.resourceId)).toEqual([ 'hero', 'enemy', ]); expect( document.querySelector('[data-resource-reference-id="hero"]'), ).not.toBeNull(); }); test('loads image thumbnails through the controlled native preview command', async () => { const user = userEvent.setup(); const invoke = vi.fn().mockResolvedValue({ dataUrl: 'data:image/png;base64,iVBORw0KGgo=', }); ( window as unknown as { __TAURI__?: { core?: { invoke?: typeof invoke } }; } ).__TAURI__ = { core: { invoke } }; render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await waitFor(() => { expect( document.querySelector('.resource-reference-picker-list img'), ).not.toBeNull(); }); expect(invoke).toHaveBeenCalledWith( 'read_local_project_image_preview', expect.objectContaining({ projectPath: 'C:/project', relativePath: 'assets/hero.png', }), ); delete ( window as unknown as { __TAURI__?: { core?: { invoke?: typeof invoke } }; } ).__TAURI__; }); test('filters candidate references by name, id, kind, and functional category', () => { const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker'); expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true); expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true); expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false); expect(resourceReferenceCategory(hero)).toBe('character'); expect(resourceReferenceCategory(theme)).toBe('audio'); expect(hero.tags).toEqual([]); }); test('exposes the six functional categories plus all as the single filter source', () => { expect(RESOURCE_REFERENCE_FILTERS).toEqual([ { id: 'all', label: '全部' }, { id: 'ui-interaction', label: 'UI 交互' }, { id: 'character', label: '角色与对象' }, { id: 'scene', label: '场景与环境' }, { id: 'audio', label: '音频' }, { id: 'document', label: '文档' }, { id: 'unclassified', label: '待归类' }, ]); }); test('projects explicit category and manifest tags onto the reference', () => { const hero = resourceReferenceFromAsset( { ...assets[0]!, category: 'scene', tags: ['主舞台', '日夜'], }, 'asset-picker', ); expect(hero.category).toBe('scene'); expect(hero.tags).toEqual(['主舞台', '日夜']); expect(resourceReferenceMatchesCategoryFilter(hero, 'scene')).toBe(true); expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe( false, ); expect(resourceReferenceMatchesCategoryFilter(hero, 'all')).toBe(true); }); test('falls back to the kind-derived category when the manifest asset omits it', () => { const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); const unclassified = resourceReferenceFromAsset( { ...assets[0]!, kind: 'image', }, 'asset-picker', ); expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe( true, ); expect(resourceReferenceCategory(unclassified)).toBe('unclassified'); }); test('resource-card insertion uses the shared structured reference event', () => { const reference = resourceReferenceFromAsset(assets[0]!, 'resource-card'); const listener = vi.fn(); window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener); dispatchResourceReferenceInsert(reference); window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener); expect(listener).toHaveBeenCalledTimes(1); expect( (listener.mock.calls[0]?.[0] as CustomEvent).detail.reference, ).toEqual(reference); }); test('runtime inspect messages only expose the bounded safe selection shape', () => { expect( parseLocalGamePreviewInspectMessage({ type: LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, action: 'selected', selection: { label: '开始游戏', elementTag: 'button', elementRole: 'button', text: '开始游戏', width: 120.4, height: 40.2, // 资源 id 允许 `:` 与非 ASCII(画布规范形态是 `local-asset:`, // 落盘文件名派生的 id 还可能是 `persisted-角色草图.png`); // 空白与路径分隔符不是资源 id,照旧丢掉。 resourceIds: [ 'hero', 'local-asset:hero', 'persisted-角色草图.png', 'bad id', '../secret', 'a/b', ], sourcePath: '/assets/hero.png?token=secret', html: '', }, }), ).toEqual({ action: 'selected', selection: { label: '开始游戏', elementTag: 'button', elementRole: 'button', text: '开始游戏', width: 120.4, height: 40.2, resourceIds: ['hero', 'local-asset:hero', 'persisted-角色草图.png'], sourcePath: '/assets/hero.png', }, }); expect( parseLocalGamePreviewInspectMessage({ type: 'unknown', action: 'selected', }), ).toBeNull(); }); test('runtime-region references travel through the same structured event', () => { const reference: ChatReference = { type: 'runtime-region', label: '开始按钮', runId: 'preview-3101', elementTag: 'button', text: '开始游戏', resourceIds: ['hero'], source: 'runtime-picker', }; const listener = vi.fn(); window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener); dispatchResourceReferenceInsert(reference); window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, listener); expect( (listener.mock.calls[0]?.[0] as CustomEvent).detail.reference, ).toEqual(reference); }); test('removing a chip keeps the remaining text editable', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await screen.findByRole('button', { name: '移除引用 hero' }); await user.click(screen.getByRole('button', { name: '移除引用 hero' })); await waitFor(() => { expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); }); }); test('counts one inserted reference as a single character in the editor text model', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); expect( document.querySelector('[data-resource-reference-id="hero"]'), ).not.toBeNull(); // 引用是原子节点:编辑器文本模型里只占 1 个字符,加上插入时补的那个分隔空格 // 一共 2 个。显示名 `hero` 有 4 个字符(`@hero` 是 5),如果引用按显示名算, // 这里会是 5 或 6,所以这条断言正好把「1 个引用 = 1 个字符」钉住。 // 提交用的结构化引用另走 `references`,不受这个占位字符影响。 expect(editorTextSize()).toBe(2); }); test('keeps the whole reference after one delete and drops its data in one step', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); // chip 仍把 `@显示名` 显示给用户看;删除按钮挂在 chip 自己身上 // (`移除引用 hero` 是 chip 内部的按钮,不是脱离 chip 的独立按钮)。 const chip = document.querySelector('[data-resource-reference-id="hero"]'); expect(chip).not.toBeNull(); expect(chip?.textContent).toBe('@hero'); const deleteButton = screen.getByRole('button', { name: '移除引用 hero' }); expect(chip?.contains(deleteButton)).toBe(true); // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( 'hero', ); // 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。 await user.click(deleteButton); await settleComposer(); expect( document.querySelector('[data-resource-reference-id="hero"]'), ).toBeNull(); expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); expect(onChange.mock.calls.at(-1)?.[0].text).toBe(''); }); test('exposes the current-version and all-canvas scopes as the only two tabs', () => { expect(RESOURCE_REFERENCE_SCOPES).toEqual([ { id: 'current-version', label: '当前版本素材' }, { id: 'all-canvas', label: '全部画布素材' }, ]); }); test('resolves the current version from activeVersionId or the newest manifest version', () => { const versions = [ iterationVersion('v1', ['hero']), iterationVersion('v2', ['theme'], 'v1'), ]; expect(resolveActiveIterationVersion(versions, 'v1')?.versionId).toBe('v1'); expect(resolveActiveIterationVersion(versions, null)?.versionId).toBe('v2'); expect(resolveActiveIterationVersion(versions, undefined)?.versionId).toBe( 'v2', ); expect(resolveActiveIterationVersion(versions, 'missing')).toBeNull(); expect(resolveActiveIterationVersion([], null)).toBeNull(); expect(resolveActiveIterationVersion(undefined, null)).toBeNull(); }); test('derives current-version assets from bindings and drops dangling bindings', () => { const versions = [iterationVersion('v1', ['hero', 'deleted-asset'])]; expect( currentIterationVersionAssets(assets, versions, 'v1').map( (entry) => entry.id, ), ).toEqual(['hero']); expect(currentIterationVersionAssets(assets, versions, 'missing')).toEqual( [], ); expect(currentIterationVersionAssets(assets, [], null)).toEqual([]); expect( currentIterationVersionAssets(assets, [iterationVersion('v1', [])], 'v1'), ).toEqual([]); }); test('keeps an independent search and filter state per picker scope', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); // 没传 activeVersionId 时回退到 manifest 最新版本 v2,只列出该版本绑定的资源。 expect( screen .getByRole('tab', { name: '当前版本素材' }) .getAttribute('aria-selected'), ).toBe('true'); expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); await user.type(screen.getByLabelText('搜索当前版本素材'), 'the'); expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); await user.click(screen.getByRole('tab', { name: '全部画布素材' })); // 另一个页签有自己的搜索与筛选,不受当前版本页签影响。 const allCanvasSearch = screen.getByLabelText( '搜索全部画布素材', ) as HTMLInputElement; expect(allCanvasSearch.value).toBe(''); expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull(); await user.type(allCanvasSearch, 'hero'); expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull(); await user.click(screen.getByRole('button', { name: '音频' })); expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); expect(screen.getByText('没有匹配的素材')).not.toBeNull(); await user.click(screen.getByRole('tab', { name: '当前版本素材' })); expect( (screen.getByLabelText('搜索当前版本素材') as HTMLInputElement).value, ).toBe('the'); expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); }); test('shows an empty state when the active version has no bound resources', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); // 当前版本没有可用素材时默认落到「全部画布素材」,切回当前版本页签是空态。 await user.click(screen.getByRole('tab', { name: '当前版本素材' })); expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull(); expect(screen.queryByRole('option')).toBeNull(); await user.click(screen.getByRole('tab', { name: '全部画布素材' })); expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); }); test('shows the current-version empty state when the project has no versions', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('tab', { name: '当前版本素材' })); expect(screen.getByText('当前版本还没有绑定素材')).not.toBeNull(); }); test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), assets[1]!, assets[2]!, ]; render( , ); await waitFor(() => { expect( document.querySelector('.resource-reference-chip-label')?.textContent, ).toBe('hero-final'); }); await waitFor(() => { expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( 'hero-final', ); }); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); expect(screen.queryByRole('option', { name: /hero /u })).toBeNull(); }); test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const { rerender } = render( , ); // 切换 / 重开会话:外部草稿被整体替换。 rerender( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { const references = taggedAssets.map((entry) => resourceReferenceFromAsset(entry, 'asset-picker'), ); expect(resourceReferenceTagLibrary(references)).toEqual([ { tag: '角色', assetCount: 2, assetIds: ['enemy', 'hero'] }, { tag: '背景乐', assetCount: 1, assetIds: ['theme'] }, { tag: '主角', assetCount: 1, assetIds: ['hero'] }, ]); const hero = references[0]!; const enemy = references[1]!; const theme = references[2]!; // 空选择不过滤;单标签按「素材带这个标签」判。 expect(resourceReferenceMatchesTagSelection(hero, [])).toBe(true); expect(resourceReferenceMatchesTagSelection(theme, ['角色'])).toBe(false); // 多标签是 AND:hero 同时有「角色」「主角」才算命中,enemy 只有「角色」。 expect(resourceReferenceMatchesTagSelection(hero, ['角色', '主角'])).toBe( true, ); expect(resourceReferenceMatchesTagSelection(enemy, ['角色', '主角'])).toBe( false, ); }); test('标签筛选把不符合的候选筛掉,多标签叠加,再与搜索关键字、功能分类叠加', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); // 没传 versions 时默认落在「全部画布素材」,标签行按这一页签的候选派生。 expect(screen.getAllByRole('option')).toHaveLength(3); expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe( '角色2', ); await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u })); // 只勾一个标签:没带这个标签的素材必须消失,不是"排前面"。 expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); expect(screen.getByRole('option', { name: /enemy/u })).not.toBeNull(); expect(screen.queryByRole('option', { name: /theme/u })).toBeNull(); await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u })); // 第二个标签再收一层:两个标签都命中的只剩 hero。 expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); expect(screen.queryByRole('option', { name: /enemy/u })).toBeNull(); expect( pickerTagGroup() .getByRole('button', { name: /^主角/u }) .getAttribute('aria-pressed'), ).toBe('true'); // 关键字叠在标签之上:hero 不含 'enemy',两个标签 + 关键字一起就空了。 await user.type(screen.getByLabelText('搜索全部画布素材'), 'enemy'); expect(screen.queryByRole('option')).toBeNull(); expect(screen.getByText('没有匹配的素材')).not.toBeNull(); // 功能分类也参与叠加:hero 是 character,切到「音频」后同样为空。 await user.clear(screen.getByLabelText('搜索全部画布素材')); await user.click(screen.getByRole('button', { name: '音频' })); expect(screen.queryByRole('option')).toBeNull(); // 取消两个标签,分类仍在生效:只剩音频素材 theme。 await user.click(pickerTagGroup().getByRole('button', { name: /^主角/u })); await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u })); expect(screen.getByRole('option', { name: /theme/u })).not.toBeNull(); expect(screen.queryByRole('option', { name: /hero/u })).toBeNull(); }); test('两个页签各自持有一份独立的标签库与已选标签', async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole('button', { name: '插入素材引用' })); // 当前版本 v1 只绑定 hero / theme,标签计数也只看这两个。 expect( screen .getByRole('tab', { name: '当前版本素材' }) .getAttribute('aria-selected'), ).toBe('true'); expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe( '角色1', ); expect(pickerTagGroup().queryByText('主角')).not.toBeNull(); await user.click(pickerTagGroup().getByRole('button', { name: /^角色/u })); expect(screen.getByRole('option', { name: /hero/u })).not.toBeNull(); expect(screen.queryByRole('option', { name: /theme/u })).toBeNull(); await user.click(screen.getByRole('tab', { name: '全部画布素材' })); // 另一个页签有自己的已选标签(此时为空)与自己的标签计数。 expect(screen.getAllByRole('option')).toHaveLength(3); expect( pickerTagGroup() .getByRole('button', { name: /^角色/u }) .getAttribute('aria-pressed'), ).toBe('false'); expect(pickerTagGroup().getByText('角色').parentElement?.textContent).toBe( '角色2', ); await user.click(screen.getByRole('tab', { name: '当前版本素材' })); // 切回来仍是自己那份选择与筛后结果。 expect( pickerTagGroup() .getByRole('button', { name: /^角色/u }) .getAttribute('aria-pressed'), ).toBe('true'); expect(screen.queryByRole('option', { name: /theme/u })).toBeNull(); }); test('聊天与快速编辑提示词插入同一素材:草稿文本与引用完全一致', async () => { const chatOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); const quickEditOnChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( <> , ); await insertAssetThroughPicker('聊天', /hero/u); await insertAssetThroughPicker('快速编辑提示词', /hero/u); const chatDraft = chatOnChange.mock.calls.at(-1)?.[0]; const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0]; // 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同: // 「快速编辑」不允许出现第二种引用格式。 expect(chatDraft?.text).toBe('@hero'); expect(quickEditDraft).toEqual(chatDraft); expect(quickEditDraft?.references).toEqual([ resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'), ]); expect( document.querySelectorAll('[data-resource-reference-id="hero"]'), ).toHaveLength(2); }); test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => { render( , ); expect(screen.queryByRole('button', { name: 'AI 润色' })).toBeNull(); // `@` 入口照旧在:润色收起来不影响引用。 expect( referenceInputByLabel('快速编辑提示词').getByRole('button', { name: '插入素材引用', }), ).not.toBeNull(); }); });