4951b71d71
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s
项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
import {
|
|
act,
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
AdminApiError,
|
|
downloadAdminProjectSnapshot,
|
|
getAdminProjectSnapshotChannels,
|
|
listAdminProjectSnapshots,
|
|
} from '../api/adminApiClient';
|
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
|
|
|
vi.mock('../api/adminApiClient', async () => ({
|
|
...(await vi.importActual<typeof import('../api/adminApiClient')>(
|
|
'../api/adminApiClient',
|
|
)),
|
|
downloadAdminProjectSnapshot: vi.fn(),
|
|
getAdminProjectSnapshotChannels: vi.fn(),
|
|
listAdminProjectSnapshots: vi.fn(),
|
|
}));
|
|
|
|
const entry: AdminProjectSnapshotEntry = {
|
|
userId: 'user-1',
|
|
projectId: 'project-1',
|
|
projectName: '三消工程',
|
|
syncRevision: 3,
|
|
syncedAtMs: 1_700_000_000_000,
|
|
fileCount: 12,
|
|
totalBytes: 2048,
|
|
status: 'ready',
|
|
channel: 'dev',
|
|
authorDisplayName: '陶泥作者',
|
|
authorPublicUserCode: 'SY-00000007',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockReset()
|
|
.mockResolvedValue({ items: [entry], nextCursor: null });
|
|
vi.mocked(getAdminProjectSnapshotChannels)
|
|
.mockReset()
|
|
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
|
|
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('按项目展示完整性并限制未完成工程下载', async () => {
|
|
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
|
items: [
|
|
entry,
|
|
{
|
|
...entry,
|
|
projectId: 'partial-project',
|
|
projectName: '未完成工程',
|
|
status: 'partial',
|
|
},
|
|
{
|
|
...entry,
|
|
projectId: 'legacy-project',
|
|
projectName: null,
|
|
status: 'unverified',
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
|
|
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
|
|
expect(
|
|
within(completeRow)
|
|
.getByRole('button', { name: '下载完整工程' })
|
|
.hasAttribute('disabled'),
|
|
).toBe(false);
|
|
expect(
|
|
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
expect(screen.getByText('完整性未知')).toBeTruthy();
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '下载已存文件' })
|
|
.hasAttribute('disabled'),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('按游标翻页回到上一页时复用已取得的游标', async () => {
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
|
nextCursor: 'page-3',
|
|
})
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
const pagination = await screen.findByRole('navigation', {
|
|
name: '项目工程分页',
|
|
});
|
|
expect(pagination.textContent).toContain('第 1 页');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
1,
|
|
'token',
|
|
{ cursor: null, limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(
|
|
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
await screen.findByText('第二工程');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
2,
|
|
'token',
|
|
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(screen.queryByText('三消工程')).toBeNull();
|
|
expect(pagination.textContent).toContain('第 2 页');
|
|
expect(
|
|
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
|
).toBe(false);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
|
await screen.findByText('三消工程');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
3,
|
|
'token',
|
|
{ cursor: null, limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(pagination.textContent).toContain('第 1 页');
|
|
});
|
|
|
|
test('切换每页条数从第一页按新条数重新加载', async () => {
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
|
nextCursor: 'page-3',
|
|
})
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
await screen.findByText('第二工程');
|
|
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
|
expect(pagination.textContent).toContain('第 2 页');
|
|
|
|
fireEvent.change(screen.getByLabelText('每页条数'), {
|
|
target: { value: '50' },
|
|
});
|
|
await screen.findByText('三消工程');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
3,
|
|
'token',
|
|
{ cursor: null, limit: 50, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
// 重新加载时页脚会重建,必须重新取节点再断言。
|
|
expect(
|
|
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
|
|
).toContain('第 1 页');
|
|
});
|
|
|
|
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
|
nextCursor: null,
|
|
})
|
|
.mockRejectedValueOnce(new Error('翻页读取失败'));
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
await screen.findByText('第二工程');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
await screen.findByRole('alert');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
3,
|
|
'token',
|
|
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
|
|
expect(pagination.textContent).toContain('第 2 页');
|
|
expect(screen.getByText('第二工程')).toBeTruthy();
|
|
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
|
|
|
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
|
items: [],
|
|
nextCursor: null,
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
await screen.findByText('暂无已上传项目');
|
|
expect(screen.queryByRole('alert')).toBeNull();
|
|
});
|
|
|
|
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
const row = (await screen.findByText('三消工程')).closest('tr')!;
|
|
expect(within(row).getByText('陶泥作者')).toBeTruthy();
|
|
expect(within(row).getByText('SY-00000007')).toBeTruthy();
|
|
expect(
|
|
within(row).getByRole('button', { name: '查看用户信息' }),
|
|
).toBeTruthy();
|
|
expect(within(row).queryByText('user-1')).toBeNull();
|
|
});
|
|
|
|
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
|
|
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
|
defaultChannel: 'release',
|
|
channels: ['dev', 'release'],
|
|
});
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
|
|
nextCursor: null,
|
|
});
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
1,
|
|
'token',
|
|
{ cursor: null, limit: 20, channel: 'release' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
const channelSelect = screen.getByLabelText('项目工程渠道');
|
|
expect((channelSelect as HTMLSelectElement).value).toBe('release');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
await screen.findByText('第 2 页,本页 1 个项目');
|
|
fireEvent.change(channelSelect, { target: { value: 'dev' } });
|
|
await screen.findByText('dev 工程');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
3,
|
|
'token',
|
|
{ cursor: null, limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
|
|
});
|
|
|
|
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
|
const createObjectURL = vi.fn(() => 'blob:archive');
|
|
const revokeObjectURL = vi.fn();
|
|
vi.stubGlobal(
|
|
'URL',
|
|
class extends URL {
|
|
static createObjectURL = createObjectURL;
|
|
static revokeObjectURL = revokeObjectURL;
|
|
},
|
|
);
|
|
let savedFilename = '';
|
|
let savedHref = '';
|
|
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
this: HTMLAnchorElement,
|
|
) {
|
|
savedFilename = this.download;
|
|
savedHref = this.href;
|
|
});
|
|
const blob = new Blob(['PK'], { type: 'application/zip' });
|
|
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
|
|
blob,
|
|
filename: '三消工程-r3.zip',
|
|
});
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
const button = await screen.findByRole('button', { name: '下载完整工程' });
|
|
vi.useFakeTimers();
|
|
await act(async () => {
|
|
fireEvent.click(button);
|
|
});
|
|
expect(savedFilename).toBe('三消工程-r3.zip');
|
|
expect(savedHref).toBe('blob:archive');
|
|
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
|
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
|
'token',
|
|
'dev',
|
|
'user-1',
|
|
'project-1',
|
|
expect.any(AbortSignal),
|
|
);
|
|
act(() => vi.advanceTimersByTime(1000));
|
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
|
|
});
|
|
|
|
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
|
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
|
(_token, _channel, _user, _project, signal) =>
|
|
new Promise((_resolve, reject) => {
|
|
signal?.addEventListener('abort', () =>
|
|
reject(new DOMException('Aborted', 'AbortError')),
|
|
);
|
|
}),
|
|
);
|
|
const view = render(
|
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
|
expect(
|
|
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
|
|
).toBe(true);
|
|
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
|
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
|
|
view.unmount();
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
|
|
const onUnauthorized = vi.fn();
|
|
vi.mocked(downloadAdminProjectSnapshot)
|
|
.mockRejectedValueOnce(
|
|
new AdminApiError({ status: 403, message: '无项目工程权限' }),
|
|
)
|
|
.mockRejectedValueOnce(
|
|
new AdminApiError({ status: 401, message: '已过期' }),
|
|
);
|
|
render(
|
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
|
|
expect(onUnauthorized).not.toHaveBeenCalled();
|
|
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
|
|
await waitFor(() =>
|
|
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
);
|
|
});
|
|
|
|
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
|
|
const onUnauthorized = vi.fn();
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockRejectedValueOnce(new Error('清单存储不可用'))
|
|
.mockRejectedValueOnce(
|
|
new AdminApiError({ status: 401, message: '已过期' }),
|
|
);
|
|
render(
|
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
|
);
|
|
await screen.findByText('清单存储不可用');
|
|
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
await waitFor(() =>
|
|
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
);
|
|
});
|
|
|
|
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
|
let finishOldRequest!: (value: {
|
|
items: AdminProjectSnapshotEntry[];
|
|
nextCursor: null;
|
|
}) => void;
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
finishOldRequest = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectName: '新账号工程' }],
|
|
nextCursor: null,
|
|
});
|
|
const onUnauthorized = vi.fn();
|
|
const view = render(
|
|
<AdminProjectSnapshotsPage
|
|
token="old-token"
|
|
onUnauthorized={onUnauthorized}
|
|
/>,
|
|
);
|
|
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
|
await waitFor(() =>
|
|
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
|
);
|
|
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
|
view.rerender(
|
|
<AdminProjectSnapshotsPage
|
|
token="new-token"
|
|
onUnauthorized={onUnauthorized}
|
|
/>,
|
|
);
|
|
await screen.findByText('新账号工程');
|
|
expect(oldSignal?.aborted).toBe(true);
|
|
await act(async () => {
|
|
finishOldRequest({ items: [entry], nextCursor: null });
|
|
});
|
|
expect(screen.queryByText('三消工程')).toBeNull();
|
|
expect(screen.getByText('新账号工程')).toBeTruthy();
|
|
});
|
|
|
|
test('卸载后完成的下载不会创建浏览器文件', async () => {
|
|
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
|
|
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
|
|
new Promise((resolve) => {
|
|
finishDownload = resolve;
|
|
}),
|
|
);
|
|
const click = vi
|
|
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
.mockImplementation(() => {});
|
|
const view = render(
|
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
|
);
|
|
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
|
|
view.unmount();
|
|
expect(signal?.aborted).toBe(true);
|
|
await act(async () => {
|
|
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
|
|
});
|
|
expect(click).not.toHaveBeenCalled();
|
|
});
|