import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GameAssetBrowser } from '@/pages/Chat/GameAssetBrowser'; import { AppError } from '@/lib/error-model'; const hostApiFetchMock = vi.fn(); vi.mock('@/lib/host-api', () => ({ hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args) })); const assets = [ { id: 'hero', name: '主角', category: 'visual', status: 'candidate', purpose: '玩家角色', source: 'Kenney', license: 'CC0', mediaKind: 'image', previewDataUrl: 'data:image/png;base64,AA==', manifest: [] }, { id: 'jump', name: '跳跃音效', category: 'audio', status: 'candidate', purpose: '跳跃反馈', source: 'Kenney', license: 'CC0', mediaKind: 'audio', audioDataUrl: 'data:audio/wav;base64,AA==', manifest: [] }, ]; function review(overrides: Record = {}) { return { invocationId: 'review-1', candidateIds: ['hero', 'jump'], decisions: {}, status: 'pending', pendingAssetIds: ['hero', 'jump'], approvedAssetIds: [], discardedAssetIds: [], ...overrides, }; } describe('desktop game asset review tool', () => { beforeEach(() => { hostApiFetchMock.mockReset(); hostApiFetchMock.mockImplementation((path: string, init?: RequestInit) => { if (path.startsWith('/api/files/game-asset-review?')) return Promise.resolve({ review: review(), assets }); if (path === '/api/files/game-asset-review' && init?.method === 'POST') { const body = JSON.parse(String(init.body)) as { decisions: Array<{ assetId: string; action: string }> }; const decisions = Object.fromEntries(body.decisions.map(({ assetId, action }) => [ assetId, action === 'approve' ? 'approved' : action === 'replace' ? 'replace-requested' : 'discarded', ])); const approvedAssetIds = body.decisions.filter(({ action }) => action === 'approve').map(({ assetId }) => assetId); const discardedAssetIds = body.decisions.filter(({ action }) => action !== 'approve').map(({ assetId }) => assetId); return Promise.resolve({ success: true, review: review({ decisions, status: 'resolved', pendingAssetIds: [], approvedAssetIds, discardedAssetIds, }), }); } throw new Error(`Unexpected Host API call: ${path}`); }); }); it('keeps card choices local and sends one aggregated decision after final confirmation', async () => { const onSubmit = vi.fn(); render(); expect(await screen.findByText('主角')).toBeInTheDocument(); expect(screen.getByText('跳跃音效')).toBeInTheDocument(); expect(document.querySelector('audio')).toHaveAttribute('src', 'data:audio/wav;base64,AA=='); fireEvent.click(screen.getByRole('button', { name: '纳入开发 主角' })); expect(onSubmit).not.toHaveBeenCalled(); expect(hostApiFetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'POST')).toBe(false); expect(screen.getByText('主角')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '舍弃 跳跃音效' })); expect(screen.getByText('已选择 2 / 2 个,可以提交')).toBeInTheDocument(); const submitButton = screen.getByRole('button', { name: '查看并提交本轮结果' }); expect(submitButton).toBeEnabled(); fireEvent.click(submitButton); expect(await screen.findByRole('dialog', { name: '确认提交本轮素材审核结果' })).toBeInTheDocument(); expect(onSubmit).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '确认并一次性提交' })); await waitFor(() => expect(hostApiFetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'POST')).toBe(true)); const postCall = hostApiFetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'POST'); expect(postCall).toBeDefined(); expect(JSON.parse(String((postCall?.[1] as RequestInit).body))).toMatchObject({ invocationId: 'review-1', candidateIds: ['hero', 'jump'], decisions: [ { assetId: 'hero', action: 'approve' }, { assetId: 'jump', action: 'discard' }, ], }); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); expect(onSubmit).toHaveBeenCalledWith(assets, { hero: 'approve', jump: 'discard' }); expect(await screen.findByTestId('game-asset-review-summary')).toHaveTextContent('纳入开发 1 个,舍弃 1 个'); }); it('hides technical details when review submission fails', async () => { render(); await screen.findByText('主角'); hostApiFetchMock.mockRejectedValueOnce(new Error('HTTP 500: internal socket path /tmp/review.sock')); fireEvent.click(screen.getByRole('button', { name: '纳入开发 主角' })); fireEvent.click(screen.getByRole('button', { name: '舍弃 跳跃音效' })); fireEvent.click(screen.getByRole('button', { name: '查看并提交本轮结果' })); fireEvent.click(await screen.findByRole('button', { name: '确认并一次性提交' })); expect(await screen.findByText('素材审核结果暂时无法保存,请稍后重试')).toBeInTheDocument(); expect(screen.queryByText(/internal socket path/)).not.toBeInTheDocument(); expect(screen.queryByText(/HTTP 500/)).not.toBeInTheDocument(); }); it('renders a persisted resolved review after the app is reopened', async () => { hostApiFetchMock.mockResolvedValueOnce({ review: review({ decisions: { hero: 'approved', jump: 'discarded' }, status: 'resolved', pendingAssetIds: [], approvedAssetIds: ['hero'], discardedAssetIds: ['jump'] }), assets: [], }); render(); expect(await screen.findByTestId('game-asset-review-summary')).toHaveTextContent('本轮审核已完成'); expect(hostApiFetchMock).toHaveBeenCalledWith(expect.stringContaining('invocationId=review-1')); }); it('does not send an empty candidate list to the host route', async () => { render(); await screen.findByText('主角'); expect(hostApiFetchMock).toHaveBeenCalledWith('/api/files/game-asset-review?invocationId=review-1'); }); it('shows a local error instead of crashing when a host response omits assets', async () => { hostApiFetchMock.mockResolvedValueOnce({ review: review() }); render(); expect(await screen.findByText('旧素材卡片已失效,已自动跳过,不影响继续对话')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '重新加载' })).not.toBeInTheDocument(); expect(screen.getByTestId('game-asset-browser-stale')).toBeInTheDocument(); expect(hostApiFetchMock).toHaveBeenCalledTimes(1); }); it('skips a stale historical card without retrying or exposing the server error', async () => { const error = new AppError('PERMISSION', 'Game asset review is unavailable for an uninitialized project', undefined, { status: 403 }); hostApiFetchMock.mockRejectedValueOnce(error); render(); expect(await screen.findByText('旧素材卡片已失效,已自动跳过,不影响继续对话')).toBeInTheDocument(); expect(screen.queryByText(error.message)).not.toBeInTheDocument(); expect(hostApiFetchMock).toHaveBeenCalledTimes(1); }); it('automatically retries one transient failure and recovers silently', async () => { hostApiFetchMock .mockRejectedValueOnce(new AppError('NETWORK', 'fetch failed')) .mockResolvedValueOnce({ review: review(), assets }); render(); expect(await screen.findByText('主角')).toBeInTheDocument(); expect(hostApiFetchMock).toHaveBeenCalledTimes(2); expect(screen.queryByText('素材卡片暂时无法加载,不影响继续对话')).not.toBeInTheDocument(); }); it('offers manual reload after two transient failures without showing technical details', async () => { hostApiFetchMock .mockRejectedValueOnce(new AppError('UNKNOWN', 'HTTP 503', undefined, { status: 503 })) .mockRejectedValueOnce(new Error('socket closed')); render(); expect(await screen.findByText('素材卡片暂时无法加载,不影响继续对话')).toBeInTheDocument(); expect(screen.queryByText('HTTP 503')).not.toBeInTheDocument(); expect(screen.queryByText('socket closed')).not.toBeInTheDocument(); expect(hostApiFetchMock).toHaveBeenCalledTimes(2); expect(screen.getByRole('button', { name: '重新加载' })).toBeInTheDocument(); }); it('starts one new bounded retry round when the user reloads', async () => { hostApiFetchMock .mockRejectedValueOnce(new AppError('NETWORK', 'first round one')) .mockRejectedValueOnce(new AppError('NETWORK', 'first round two')) .mockRejectedValueOnce(new AppError('NETWORK', 'second round one')) .mockResolvedValueOnce({ review: review(), assets }); render(); fireEvent.click(await screen.findByRole('button', { name: '重新加载' })); expect(await screen.findByText('主角')).toBeInTheDocument(); expect(hostApiFetchMock).toHaveBeenCalledTimes(4); }); });