// @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 } 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, dispatchResourceReferenceInsert, RESOURCE_REFERENCE_INSERT_EVENT, resourceReferenceFilterKind, resourceReferenceFromAsset, resourceReferenceMatchesQuery, } 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' }, }; } 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 media 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(resourceReferenceFilterKind(hero)).toBe('image'); expect(resourceReferenceFilterKind(theme)).toBe('audio'); }); 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); }); }); });