Files
makelore/tests/unit/game-asset-browser-ui.test.tsx
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

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);
});
});