185 lines
9.8 KiB
TypeScript
185 lines
9.8 KiB
TypeScript
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<string, unknown> = {}) {
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={onSubmit} />);
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={[]} onSubmit={vi.fn()} />);
|
|
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(<GameAssetBrowser invocationId="legacy-review" candidateIds={['hero']} onSubmit={vi.fn()} />);
|
|
|
|
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(<GameAssetBrowser invocationId="legacy-review" candidateIds={['hero']} onSubmit={vi.fn()} />);
|
|
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
|
|
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={['hero']} onSubmit={vi.fn()} />);
|
|
|
|
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(<GameAssetBrowser invocationId="review-1" candidateIds={['hero', 'jump']} onSubmit={vi.fn()} />);
|
|
fireEvent.click(await screen.findByRole('button', { name: '重新加载' }));
|
|
|
|
expect(await screen.findByText('主角')).toBeInTheDocument();
|
|
expect(hostApiFetchMock).toHaveBeenCalledTimes(4);
|
|
});
|
|
});
|