a344686b59
Project CI / AI game creator shell Rust smoke (push) Successful in 1m44s
Project CI / AI game creator shell Rust crates (push) Successful in 1m9s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
新增按钮读取所选渠道全部项目并按同步时间降序、用户与项目 ID 升序展示 复用本地分页并支持全量刷新、恢复默认顺序、进度提示与取消读取 为全量读取增加超时、数量和游标边界,失败时保留原结果 补充三项关键交互测试并同步技术方案、运维说明与共享记忆 Reviewed-on: #546
670 lines
25 KiB
TypeScript
670 lines
25 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,
|
|
AdminProjectSnapshotListResponse,
|
|
} 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 () => {
|
|
const olderProjects = Array.from({ length: 22 }, (_, index) => ({
|
|
...entry,
|
|
projectId: `older-${index}`,
|
|
projectName: `旧项目 ${index}`,
|
|
syncedAtMs: entry.syncedAtMs + index,
|
|
}));
|
|
const tiedProjects = [
|
|
{ ...entry, userId: 'user-2', projectId: 'p-1', projectName: '同秒乙' },
|
|
{ ...entry, userId: 'user-1', projectId: 'p-2', projectName: '同秒甲二' },
|
|
{ ...entry, userId: 'user-1', projectId: 'p-1', projectName: '同秒甲一' },
|
|
].map((project) => ({ ...project, syncedAtMs: entry.syncedAtMs + 100 }));
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
|
|
.mockResolvedValueOnce({ items: olderProjects, nextCursor: 'scan-2' })
|
|
// 后端有界扫描可能返回带下一游标的空页,不能提前结束。
|
|
.mockResolvedValueOnce({ items: [], nextCursor: 'scan-3' })
|
|
.mockResolvedValueOnce({
|
|
items: [
|
|
...tiedProjects,
|
|
{
|
|
...olderProjects[0]!,
|
|
projectName: '更新项目',
|
|
syncedAtMs: entry.syncedAtMs + 200,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
|
await screen.findByRole('button', { name: '恢复默认顺序' });
|
|
const rowNames = () =>
|
|
screen
|
|
.getAllByRole('row')
|
|
.map((row) => row.querySelector('td strong')?.textContent)
|
|
.filter(Boolean);
|
|
expect(rowNames().slice(0, 4)).toEqual([
|
|
'更新项目',
|
|
'同秒甲一',
|
|
'同秒甲二',
|
|
'同秒乙',
|
|
]);
|
|
expect(rowNames()).toHaveLength(20);
|
|
expect(screen.getByRole('navigation').textContent).toContain('共 25 个');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
4,
|
|
'token',
|
|
{ cursor: 'scan-3', limit: 100, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
expect(rowNames()).toHaveLength(5);
|
|
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
|
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
|
expect(rowNames()[0]).toBe('更新项目');
|
|
fireEvent.change(screen.getByLabelText('每页条数'), {
|
|
target: { value: '50' },
|
|
});
|
|
expect(rowNames()).toHaveLength(25);
|
|
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(4);
|
|
|
|
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
|
items: [{ ...entry, projectName: '刷新后的项目' }],
|
|
nextCursor: null,
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
await screen.findByText('刷新后的项目');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
5,
|
|
'token',
|
|
{ cursor: null, limit: 100, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
|
await screen.findByText('三消工程');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
6,
|
|
'token',
|
|
{ cursor: null, limit: 50, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
});
|
|
|
|
test('恢复默认顺序失败保留排序分页,重试成功后切换远端第一页及游标', async () => {
|
|
const projects = Array.from({ length: 45 }, (_, index) => ({
|
|
...entry,
|
|
projectId: `sorted-${index}`,
|
|
projectName: `排序项目 ${index}`,
|
|
syncedAtMs: entry.syncedAtMs - index,
|
|
}));
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
|
.mockResolvedValueOnce({ items: projects, nextCursor: null })
|
|
.mockRejectedValueOnce(new Error('恢复失败'))
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectName: '默认第一页' }],
|
|
nextCursor: 'default-next',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectName: '默认第二页' }],
|
|
nextCursor: null,
|
|
});
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
|
await screen.findByText('排序项目 0');
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
|
await screen.findByText('恢复失败');
|
|
expect(screen.getByText('排序项目 20')).toBeTruthy();
|
|
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
|
expect(screen.getByRole('navigation').textContent).toContain('共 45 个');
|
|
expect(screen.getByRole('button', { name: '恢复默认顺序' })).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
expect(screen.getByText('排序项目 40')).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
|
|
expect(screen.getByText('排序项目 20')).toBeTruthy();
|
|
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(3);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '恢复默认顺序' }));
|
|
await screen.findByText('默认第一页');
|
|
expect(screen.getByRole('navigation').textContent).toContain('第 1 页');
|
|
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
4,
|
|
'token',
|
|
{ cursor: null, limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
|
|
await screen.findByText('默认第二页');
|
|
expect(screen.getByRole('navigation').textContent).toContain('第 2 页');
|
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
5,
|
|
'token',
|
|
{ cursor: 'default-next', limit: 20, channel: 'dev' },
|
|
expect.any(AbortSignal),
|
|
);
|
|
});
|
|
|
|
test('全量排序中途失败不发布部分结果,重试刷新失败保留完整排序', async () => {
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'original-next' })
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, projectName: '部分读取项目' }],
|
|
nextCursor: 'scan-next',
|
|
})
|
|
.mockRejectedValueOnce(new Error('全量读取失败'));
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
|
await screen.findByText('全量读取失败');
|
|
expect(screen.getByText('三消工程')).toBeTruthy();
|
|
expect(screen.queryByText('部分读取项目')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '恢复默认顺序' })).toBeNull();
|
|
expect(
|
|
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
|
|
).toBe(false);
|
|
|
|
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
|
items: [{ ...entry, projectName: '完整排序项目' }],
|
|
nextCursor: null,
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
|
await screen.findByText('完整排序项目');
|
|
vi.mocked(listAdminProjectSnapshots).mockRejectedValueOnce(
|
|
new Error('刷新读取失败'),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
await screen.findByText('刷新读取失败');
|
|
expect(screen.getByText('完整排序项目')).toBeTruthy();
|
|
expect(screen.getByRole('navigation').textContent).toContain('共 1 个');
|
|
});
|
|
|
|
test('取消或切换渠道时中断全量读取,忽略随后到达的旧响应', async () => {
|
|
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
|
|
defaultChannel: 'dev',
|
|
channels: ['dev', 'release'],
|
|
});
|
|
let finishCancelled!: (value: AdminProjectSnapshotListResponse) => void;
|
|
let finishOldChannel!: (value: AdminProjectSnapshotListResponse) => void;
|
|
vi.mocked(listAdminProjectSnapshots)
|
|
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
|
|
finishCancelled = resolve;
|
|
}),
|
|
)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<AdminProjectSnapshotListResponse>((resolve) => {
|
|
finishOldChannel = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce({
|
|
items: [{ ...entry, channel: 'release', projectName: '正式渠道项目' }],
|
|
nextCursor: null,
|
|
});
|
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
await screen.findByText('三消工程');
|
|
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
|
const cancelledSignal = vi.mocked(listAdminProjectSnapshots).mock
|
|
.calls[1]![2]!;
|
|
fireEvent.click(screen.getByRole('button', { name: '取消排序' }));
|
|
expect(cancelledSignal.aborted).toBe(true);
|
|
await act(async () => {
|
|
finishCancelled({
|
|
items: [{ ...entry, projectName: '已取消项目' }],
|
|
nextCursor: null,
|
|
});
|
|
});
|
|
expect(screen.getByText('三消工程')).toBeTruthy();
|
|
expect(screen.queryByText('已取消项目')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '按同步时间排序' }));
|
|
const oldChannelSignal = vi.mocked(listAdminProjectSnapshots).mock
|
|
.calls[2]![2]!;
|
|
fireEvent.change(screen.getByLabelText('项目工程渠道'), {
|
|
target: { value: 'release' },
|
|
});
|
|
await screen.findByText('正式渠道项目');
|
|
expect(oldChannelSignal.aborted).toBe(true);
|
|
await act(async () => {
|
|
finishOldChannel({
|
|
items: [{ ...entry, projectName: '旧渠道项目' }],
|
|
nextCursor: null,
|
|
});
|
|
});
|
|
expect(screen.getByText('正式渠道项目')).toBeTruthy();
|
|
expect(screen.queryByText('旧渠道项目')).toBeNull();
|
|
expect(screen.getByRole('button', { name: '按同步时间排序' })).toBeTruthy();
|
|
});
|
|
|
|
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();
|
|
});
|