// @vitest-environment jsdom import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { StrictMode } 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, currentIterationVersionAssets, dispatchResourceReferenceInsert, RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_INSERT_EVENT, RESOURCE_REFERENCE_SCOPES, resourceReferenceCategory, resourceReferenceFromAsset, resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, resolveActiveIterationVersion, } 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); }); } 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'), ]; afterEach(cleanup); describe('ResourceReferenceInput', () => { 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, resourceIds: ['hero', 'bad id', '../secret'], 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'], 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('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'); }); });