e1fe1837a2
- AdminAgcTemplatesPage 的上传弹窗把 TableHeader(thead)当列头使用,生成出 th > tr > thead 的非法层级;修正为 TableHeader > TableRow > TableHead,并补列头角色与层级回归。 - .gitignore 补 /server-rs/crates/*/server-rs/.data/:api-server 的相对 outbox 默认路径在嵌套 cwd 下会在仓库内生成内嵌数据目录,避免其被误提交。
680 lines
24 KiB
TypeScript
680 lines
24 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,
|
||
getAdminAgcTemplates,
|
||
importAdminAgcTemplates,
|
||
updateAdminAgcTemplate,
|
||
} from '../api/adminApiClient';
|
||
import type {
|
||
AdminAgcTemplateLibraryResponse,
|
||
AdminAgcTemplatePayload,
|
||
} from '../api/adminApiTypes';
|
||
import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
|
||
|
||
vi.mock('../api/adminApiClient', async (importOriginal) => ({
|
||
...(await importOriginal<typeof import('../api/adminApiClient')>()),
|
||
getAdminAgcTemplates: vi.fn(),
|
||
importAdminAgcTemplates: vi.fn(),
|
||
updateAdminAgcTemplate: vi.fn(),
|
||
}));
|
||
|
||
const template: AdminAgcTemplatePayload = {
|
||
id: 'cocos-empty-2d',
|
||
title: '空白 2D',
|
||
summary: '二维项目',
|
||
tags: ['2D', '入门'],
|
||
runtime: 'cocos',
|
||
engine: 'Cocos Creator',
|
||
engineVersion: '3.8.8',
|
||
templateVersion: '1',
|
||
enabled: true,
|
||
coverUrl: 'https://example.test/cover.png',
|
||
zipSizeBytes: 2048,
|
||
};
|
||
const library: AdminAgcTemplateLibraryResponse = {
|
||
revision: 'rev-1',
|
||
writable: true,
|
||
templates: [
|
||
template,
|
||
{
|
||
...template,
|
||
id: 'godot-starter',
|
||
title: 'Godot 起步',
|
||
runtime: 'godot',
|
||
engine: 'Godot',
|
||
engineVersion: '4',
|
||
tags: ['3D'],
|
||
enabled: false,
|
||
},
|
||
],
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(getAdminAgcTemplates)
|
||
.mockReset()
|
||
.mockImplementation(async () => structuredClone(library));
|
||
vi.mocked(updateAdminAgcTemplate)
|
||
.mockReset()
|
||
.mockImplementation(async (_token, id, update) => ({
|
||
...structuredClone(library),
|
||
revision: 'rev-2',
|
||
templates: library.templates.map((entry) =>
|
||
entry.id === id
|
||
? {
|
||
...entry,
|
||
title: update.title,
|
||
summary: update.summary,
|
||
tags: update.tags,
|
||
enabled: update.enabled,
|
||
}
|
||
: entry,
|
||
),
|
||
}));
|
||
vi.mocked(importAdminAgcTemplates)
|
||
.mockReset()
|
||
.mockImplementation(async () => ({
|
||
...structuredClone(library),
|
||
revision: 'rev-imported',
|
||
imported: [
|
||
{
|
||
id: 'alpha',
|
||
templateVersion: '0.1.0',
|
||
zipSizeBytes: 4,
|
||
zipSha256: 'a'.repeat(64),
|
||
reusedObjects: false,
|
||
},
|
||
],
|
||
}));
|
||
vi.stubGlobal(
|
||
'URL',
|
||
Object.assign(class extends URL {}, {
|
||
createObjectURL: vi.fn(() => 'blob:template-cover'),
|
||
revokeObjectURL: vi.fn(),
|
||
}),
|
||
);
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
async function openEditor() {
|
||
fireEvent.click(await screen.findByRole('button', { name: '编辑 空白 2D' }));
|
||
return screen.getByRole('dialog', { name: '编辑模板' });
|
||
}
|
||
|
||
async function confirmWrite() {
|
||
const confirmation = await screen.findByRole('dialog', { name: '确认操作' });
|
||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||
}
|
||
|
||
function deferred<T>() {
|
||
let resolve!: (value: T) => void;
|
||
let reject!: (reason: unknown) => void;
|
||
const promise = new Promise<T>((next, fail) => {
|
||
resolve = next;
|
||
reject = fail;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
test('按名称、ID、标签、运行时和上下架状态筛选,展示版本与封面', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
await screen.findByText('空白 2D');
|
||
expect(screen.getByText('Cocos Creator 3.8.8')).not.toBeNull();
|
||
expect(screen.getAllByText('模板 1')).toHaveLength(2);
|
||
expect(screen.getAllByText('2.0 KiB')).toHaveLength(2);
|
||
expect(
|
||
screen.getByRole('img', { name: '空白 2D封面' }).getAttribute('src'),
|
||
).toBe(template.coverUrl);
|
||
for (const value of ['空白', 'cocos-empty-2d', '入门']) {
|
||
fireEvent.change(screen.getByLabelText('搜索模板'), { target: { value } });
|
||
expect(screen.getByText('空白 2D')).not.toBeNull();
|
||
expect(screen.queryByText('Godot 起步')).toBeNull();
|
||
}
|
||
fireEvent.change(screen.getByLabelText('搜索模板'), {
|
||
target: { value: '' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('运行时'), {
|
||
target: { value: 'godot' },
|
||
});
|
||
expect(screen.queryByText('空白 2D')).toBeNull();
|
||
fireEvent.change(screen.getByLabelText('上架状态'), {
|
||
target: { value: 'enabled' },
|
||
});
|
||
expect(screen.getByText('没有符合筛选条件的模板')).not.toBeNull();
|
||
});
|
||
|
||
test('独立弹窗保存经写确认,只提交展示字段和冻结 revision', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const editor = await openEditor();
|
||
expect(editor.closest('.admin-agc-templates')).toBeNull();
|
||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||
target: { value: ' 新名称 ' },
|
||
});
|
||
fireEvent.change(within(editor).getByLabelText('简介'), {
|
||
target: { value: '新简介' },
|
||
});
|
||
fireEvent.change(within(editor).getByLabelText('标签'), {
|
||
target: { value: '2D, 新手,2D' },
|
||
});
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||
await confirmWrite();
|
||
await waitFor(() =>
|
||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
|
||
expectedRevision: 'rev-1',
|
||
title: '新名称',
|
||
summary: '新简介',
|
||
tags: ['2D', '新手'],
|
||
enabled: true,
|
||
}),
|
||
);
|
||
await waitFor(() =>
|
||
expect(screen.queryByRole('dialog', { name: '编辑模板' })).toBeNull(),
|
||
);
|
||
expect(screen.getByText('新名称')).not.toBeNull();
|
||
expect(screen.getByText('已保存')).not.toBeNull();
|
||
});
|
||
|
||
test('上下架复用确认且只用服务端结果更新列表', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
fireEvent.click(await screen.findByRole('button', { name: '下架 空白 2D' }));
|
||
await confirmWrite();
|
||
await waitFor(() =>
|
||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
|
||
expectedRevision: 'rev-1',
|
||
title: template.title,
|
||
summary: template.summary,
|
||
tags: template.tags,
|
||
enabled: false,
|
||
}),
|
||
);
|
||
expect(
|
||
await screen.findByRole('button', { name: '上架 空白 2D' }),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
test('只读模式禁用字段、保存与上下架', async () => {
|
||
vi.mocked(getAdminAgcTemplates).mockResolvedValue({
|
||
...library,
|
||
writable: false,
|
||
});
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
expect(
|
||
await screen.findByText('当前为只读模式,无法保存或上下架'),
|
||
).not.toBeNull();
|
||
expect(
|
||
(screen.getByRole('button', { name: '下架 空白 2D' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
const editor = await openEditor();
|
||
expect(
|
||
(within(editor).getByLabelText('名称') as HTMLInputElement).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(within(editor).getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('409 保留草稿,显式刷新后重新编辑才采用新 revision,不自动重试', async () => {
|
||
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
|
||
new AdminApiError({ message: '模板库已被修改', status: 409 }),
|
||
);
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
let editor = await openEditor();
|
||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||
target: { value: '未保存草稿' },
|
||
});
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
await confirmWrite();
|
||
await screen.findByText('模板库已被修改');
|
||
expect((screen.getByLabelText('名称') as HTMLInputElement).value).toBe(
|
||
'未保存草稿',
|
||
);
|
||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
|
||
expect(
|
||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
vi.mocked(getAdminAgcTemplates).mockResolvedValueOnce({
|
||
...library,
|
||
revision: 'rev-3',
|
||
templates: [{ ...template, title: '最新名称' }],
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新后重新编辑' }));
|
||
await screen.findByDisplayValue('最新名称');
|
||
editor = screen.getByRole('dialog', { name: '编辑模板' });
|
||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||
target: { value: '重新编辑' },
|
||
});
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
await confirmWrite();
|
||
await waitFor(() =>
|
||
expect(updateAdminAgcTemplate).toHaveBeenLastCalledWith(
|
||
'token',
|
||
template.id,
|
||
expect.objectContaining({ expectedRevision: 'rev-3', title: '重新编辑' }),
|
||
),
|
||
);
|
||
});
|
||
|
||
test('防止重复确认和保存中重复提交', async () => {
|
||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||
vi.mocked(updateAdminAgcTemplate).mockReturnValue(pending.promise);
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const editor = await openEditor();
|
||
const form = within(editor).getByLabelText('名称').closest('form')!;
|
||
fireEvent.submit(form);
|
||
fireEvent.submit(form);
|
||
expect(screen.getAllByRole('dialog', { name: '确认操作' })).toHaveLength(1);
|
||
await confirmWrite();
|
||
await waitFor(() => expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1));
|
||
fireEvent.submit(form);
|
||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
|
||
await act(async () => pending.resolve(library));
|
||
});
|
||
|
||
test.each([409, 503])(
|
||
'滚到表单底部后出现 HTTP %s 写入错误,聚焦并仅滚动弹窗到提示区',
|
||
async (status) => {
|
||
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
|
||
new AdminApiError({ message: '本次保存失败', status }),
|
||
);
|
||
render(
|
||
<div data-testid="background-page">
|
||
<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />
|
||
</div>,
|
||
);
|
||
const background = screen.getByTestId('background-page');
|
||
background.scrollTop = 240;
|
||
const editor = await openEditor();
|
||
const viewport = editor.querySelector<HTMLElement>(
|
||
'.genarrative-ui-modal__body',
|
||
)!;
|
||
viewport.scrollTop = 400;
|
||
const bounds = (top: number, bottom: number) =>
|
||
({
|
||
top,
|
||
bottom,
|
||
height: bottom - top,
|
||
left: 0,
|
||
right: 300,
|
||
width: 300,
|
||
x: 0,
|
||
y: top,
|
||
toJSON: () => ({}),
|
||
}) as DOMRect;
|
||
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(
|
||
bounds(100, 450),
|
||
);
|
||
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
|
||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||
function (this: HTMLElement) {
|
||
return this.classList.contains('admin-agc-template-feedback')
|
||
? bounds(-280, -120)
|
||
: originalBounds.call(this);
|
||
},
|
||
);
|
||
const windowScroll = vi
|
||
.spyOn(window, 'scrollTo')
|
||
.mockImplementation(() => {});
|
||
const focus = vi.spyOn(HTMLElement.prototype, 'focus');
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
await confirmWrite();
|
||
const message = await screen.findByRole('alert');
|
||
const feedback = message.parentElement!;
|
||
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
|
||
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
|
||
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
|
||
await waitFor(() => expect(document.activeElement).toBe(feedback));
|
||
expect(feedback.tabIndex).toBe(-1);
|
||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||
expect(viewport.scrollTop).toBe(20);
|
||
expect(background.scrollTop).toBe(240);
|
||
expect(windowScroll).not.toHaveBeenCalled();
|
||
if (status === 409) {
|
||
expect(
|
||
within(feedback).getByRole('button', { name: '刷新后重新编辑' }),
|
||
).not.toBeNull();
|
||
}
|
||
},
|
||
);
|
||
|
||
test('token 切换后忽略旧列表和未确认写操作', async () => {
|
||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||
vi.mocked(getAdminAgcTemplates).mockReturnValueOnce(pending.promise);
|
||
const props = { onUnauthorized: vi.fn() };
|
||
const view = render(<AdminAgcTemplatesPage token="old" {...props} />);
|
||
view.rerender(<AdminAgcTemplatesPage token="new" {...props} />);
|
||
await screen.findByText('空白 2D');
|
||
await act(async () =>
|
||
pending.resolve({
|
||
...library,
|
||
templates: [{ ...template, title: '过期列表' }],
|
||
}),
|
||
);
|
||
expect(screen.queryByText('过期列表')).toBeNull();
|
||
const editor = await openEditor();
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
view.rerender(<AdminAgcTemplatesPage token="third" {...props} />);
|
||
await screen.findByText('空白 2D');
|
||
expect(screen.queryByRole('dialog')).toBeNull();
|
||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test.each(['success', 'unauthorized'])(
|
||
'token 切换后忽略旧保存 %s,不污染新会话',
|
||
async (result) => {
|
||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||
vi.mocked(updateAdminAgcTemplate).mockReturnValueOnce(pending.promise);
|
||
const onUnauthorized = vi.fn();
|
||
const view = render(
|
||
<AdminAgcTemplatesPage token="old" onUnauthorized={onUnauthorized} />,
|
||
);
|
||
const editor = await openEditor();
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
await confirmWrite();
|
||
await waitFor(() =>
|
||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1),
|
||
);
|
||
view.rerender(
|
||
<AdminAgcTemplatesPage token="new" onUnauthorized={onUnauthorized} />,
|
||
);
|
||
await screen.findByText('空白 2D');
|
||
await act(async () => {
|
||
if (result === 'success') {
|
||
pending.resolve({
|
||
...library,
|
||
templates: [{ ...template, title: '旧保存结果' }],
|
||
});
|
||
} else {
|
||
pending.reject(
|
||
new AdminApiError({ status: 401, message: '旧会话失效' }),
|
||
);
|
||
}
|
||
});
|
||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||
expect(screen.queryByText('旧会话失效')).toBeNull();
|
||
expect(screen.queryByText('旧保存结果')).toBeNull();
|
||
expect(screen.queryByText('已保存')).toBeNull();
|
||
},
|
||
);
|
||
|
||
test('封面在草稿中转 base64,保存携带,取消释放预览 URL 且不串到下一条', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
let editor = await openEditor();
|
||
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
|
||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||
target: { files: [file] },
|
||
});
|
||
await waitFor(() => expect(screen.queryByText('正在读取封面')).toBeNull());
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
await confirmWrite();
|
||
await waitFor(() =>
|
||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith(
|
||
'token',
|
||
template.id,
|
||
expect.objectContaining({
|
||
cover: { contentType: 'image/png', dataBase64: 'Y292ZXI=' },
|
||
}),
|
||
),
|
||
);
|
||
await waitFor(() =>
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:template-cover'),
|
||
);
|
||
editor = await openEditor();
|
||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||
target: { files: [file] },
|
||
});
|
||
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '编辑 Godot 起步' }));
|
||
expect(
|
||
screen.getByRole('img', { name: '模板封面预览' }).getAttribute('src'),
|
||
).toBe(template.coverUrl);
|
||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
test.each([
|
||
['bad.svg', 'image/svg+xml', 1, '封面仅支持 PNG、JPEG 或 WebP'],
|
||
[
|
||
'large.png',
|
||
'image/png',
|
||
5 * 1024 * 1024 + 1,
|
||
'封面文件须大于 0 且不超过 5 MiB',
|
||
],
|
||
])('拒绝无效封面 %s,不上传', async (name, type, size, message) => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const editor = await openEditor();
|
||
const file = new File(['x'], name, { type });
|
||
Object.defineProperty(file, 'size', { value: size });
|
||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||
target: { files: [file] },
|
||
});
|
||
expect(screen.getByText(message)).not.toBeNull();
|
||
expect(
|
||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('名称和标签校验失败时不进入写确认', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const editor = await openEditor();
|
||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||
target: { value: ' ' },
|
||
});
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
expect(screen.getByText('名称须为 1–80 个字符')).not.toBeNull();
|
||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||
target: { value: '有效名称' },
|
||
});
|
||
fireEvent.change(within(editor).getByLabelText('标签'), {
|
||
target: {
|
||
value: Array.from({ length: 17 }, (_, index) => `标签${index}`).join(','),
|
||
},
|
||
});
|
||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||
expect(
|
||
screen.getByText('最多 16 个标签,每个标签不超过 32 个字符'),
|
||
).not.toBeNull();
|
||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test('读取失败显示真实错误,401 交给会话处理且不显示空库', async () => {
|
||
const onUnauthorized = vi.fn();
|
||
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
|
||
new AdminApiError({ status: 503, message: '模板服务不可用' }),
|
||
);
|
||
render(
|
||
<AdminAgcTemplatesPage token="token" onUnauthorized={onUnauthorized} />,
|
||
);
|
||
await screen.findByText('模板服务不可用');
|
||
expect(screen.queryByText('暂无模板')).toBeNull();
|
||
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
|
||
new AdminApiError({ status: 401, message: '会话失效' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||
await waitFor(() =>
|
||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||
);
|
||
});
|
||
|
||
function uploadZipFile(name: string) {
|
||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||
type: 'application/zip',
|
||
});
|
||
}
|
||
|
||
function uploadCoverFile(name: string) {
|
||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||
type: 'image/png',
|
||
});
|
||
}
|
||
|
||
async function openUploadDialog() {
|
||
fireEvent.click(await screen.findByRole('button', { name: '上传模板' }));
|
||
return screen.getByRole('dialog', { name: '上传模板' });
|
||
}
|
||
|
||
function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
|
||
const input = dialog.querySelector<HTMLInputElement>(
|
||
`input[accept="${accept}"]`,
|
||
);
|
||
if (!input) throw new Error(`missing file input for ${accept}`);
|
||
fireEvent.change(input, { target: { files } });
|
||
}
|
||
|
||
test('批量上传:表头是真实列头单元格,不是嵌套 thead', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const dialog = await openUploadDialog();
|
||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||
|
||
const table = Array.from(dialog.querySelectorAll('table')).find((candidate) =>
|
||
candidate.textContent?.includes('alpha.zip'),
|
||
);
|
||
expect(table).toBeTruthy();
|
||
|
||
// 每一列表头都必须是 `th`:把 `<TableHeader>`(渲染 `<thead>`)当单元格用会产出
|
||
// `<th><tr><thead>`,列头语义与样式一起丢失。
|
||
expect(
|
||
within(table as HTMLElement)
|
||
.getAllByRole('columnheader')
|
||
.map((cell) => cell.textContent),
|
||
).toEqual([
|
||
'模板包',
|
||
'ID',
|
||
'名称',
|
||
'版本',
|
||
'运行时',
|
||
'entry',
|
||
'封面',
|
||
'操作',
|
||
]);
|
||
expect(table?.querySelectorAll('thead thead')).toHaveLength(0);
|
||
expect(table?.querySelectorAll('thead > tr > th')).toHaveLength(8);
|
||
expect(table?.querySelectorAll('thead > th')).toHaveLength(0);
|
||
});
|
||
|
||
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const dialog = await openUploadDialog();
|
||
|
||
pickUploadFiles(dialog, '.zip,application/zip', [
|
||
uploadZipFile('alpha.zip'),
|
||
uploadZipFile('beta.zip'),
|
||
]);
|
||
expect(within(dialog).getByText('alpha.zip')).not.toBeNull();
|
||
expect(within(dialog).getByText('beta.zip')).not.toBeNull();
|
||
expect(
|
||
within(dialog)
|
||
.getByRole('button', { name: /上传 2 个模板/ })
|
||
.hasAttribute('disabled'),
|
||
).toBe(true);
|
||
expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2);
|
||
|
||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||
uploadCoverFile('alpha.png'),
|
||
uploadCoverFile('beta.png'),
|
||
]);
|
||
expect(within(dialog).getByText('alpha.png')).not.toBeNull();
|
||
expect(
|
||
within(dialog)
|
||
.getByRole('button', { name: /上传 2 个模板/ })
|
||
.hasAttribute('disabled'),
|
||
).toBe(false);
|
||
});
|
||
|
||
test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const dialog = await openUploadDialog();
|
||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||
uploadCoverFile('alpha.png'),
|
||
]);
|
||
|
||
fireEvent.click(
|
||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||
);
|
||
await confirmWrite();
|
||
|
||
await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1));
|
||
const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1];
|
||
const manifest = JSON.parse(String(formData.get('manifest')));
|
||
expect(manifest.expectedRevision).toBe('rev-1');
|
||
expect(manifest.templates[0]).toMatchObject({
|
||
id: 'alpha',
|
||
zipField: 'zip_0',
|
||
coverField: 'cover_0',
|
||
});
|
||
expect((formData.get('zip_0') as File).name).toBe('alpha.zip');
|
||
expect((formData.get('cover_0') as File).name).toBe('alpha.png');
|
||
expect(
|
||
(await screen.findAllByText(/已导入 1 个模板/)).length,
|
||
).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('批量上传:冲突给出刷新引导', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const dialog = await openUploadDialog();
|
||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||
uploadCoverFile('alpha.png'),
|
||
]);
|
||
|
||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||
new AdminApiError({
|
||
status: 409,
|
||
message: '模板库已更新,请刷新后重新编辑',
|
||
}),
|
||
);
|
||
fireEvent.click(
|
||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||
);
|
||
await confirmWrite();
|
||
expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull();
|
||
expect(
|
||
within(dialog)
|
||
.getByRole('button', { name: /上传 1 个模板/ })
|
||
.hasAttribute('disabled'),
|
||
).toBe(true);
|
||
});
|
||
|
||
test('批量上传:服务端拒绝时展示真实原因', async () => {
|
||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||
const dialog = await openUploadDialog();
|
||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||
uploadCoverFile('alpha.png'),
|
||
]);
|
||
|
||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||
new AdminApiError({
|
||
status: 400,
|
||
message: 'alpha:模板包缺少清单声明的 entry:index.html',
|
||
}),
|
||
);
|
||
fireEvent.click(
|
||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||
);
|
||
await confirmWrite();
|
||
expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull();
|
||
});
|