2e23a54211
Project CI / AI game creator shell Rust crates (push) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend 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 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 新增上传模型:文件名派生模板 ID、按同名匹配封面、逐行字段校验与 multipart 组装(标签去重与上限) - 模板管理页新增「上传模板」入口与弹窗:多选 ZIP、按 ID 同名多选封面、逐行编辑 ID/名称/版本/运行时/entry、逐行错误、沿用写入确认与防重复提交 - 失败只在弹窗内交代并区分 409 刷新引导与 503 锁占用提示,401 仍交由会话处理;成功后用服务端快照刷新列表并展示逐条导入结果 - admin API client 支持 multipart 请求(不预设 JSON Content-Type),新增 importAdminAgcTemplates 与对应类型 - 主规范新增「后台模板上传」章节并收口原「不上传 ZIP」表述;决策记录补一条 - 验证:admin-web typecheck、页面/模型/客户端 40 项 vitest、check:doc-index、check:encoding、cargo fmt --check、git diff --check
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
attachCoverFiles,
|
||
buildTemplateImportFormData,
|
||
deriveTemplateUploadRow,
|
||
parseTemplateTags,
|
||
sanitizeTemplateId,
|
||
TEMPLATE_IMPORT_MAX_BATCH,
|
||
type TemplateUploadRow,
|
||
validateTemplateUploadRows,
|
||
} from './adminAgcTemplateUploadModel';
|
||
|
||
function zipFile(name: string) {
|
||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||
type: 'application/zip',
|
||
});
|
||
}
|
||
|
||
function coverFile(name: string) {
|
||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||
type: 'image/png',
|
||
});
|
||
}
|
||
|
||
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
|
||
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
|
||
}
|
||
|
||
describe('sanitizeTemplateId', () => {
|
||
it('lowercases, keeps whitelisted characters and drops traversal', () => {
|
||
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
|
||
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
|
||
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
|
||
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
|
||
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
|
||
});
|
||
});
|
||
|
||
describe('deriveTemplateUploadRow', () => {
|
||
it('starts from safe defaults so a batch only needs covers attached', () => {
|
||
const derived = row('my-template.zip');
|
||
expect(derived.id).toBe('my-template');
|
||
expect(derived.title).toBe('my-template');
|
||
expect(derived.runtime).toBe('html');
|
||
expect(derived.templateVersion).toBe('0.1.0');
|
||
expect(derived.entry).toBe('index.html');
|
||
expect(derived.coverFile).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('attachCoverFiles', () => {
|
||
it('matches covers by template id and ignores non-image files', () => {
|
||
const rows = [row('alpha.zip'), row('beta.zip')];
|
||
const covers = [
|
||
coverFile('alpha.png'),
|
||
coverFile('beta.webp'),
|
||
coverFile('notes.txt'),
|
||
];
|
||
|
||
const next = attachCoverFiles(rows, covers);
|
||
|
||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||
expect(next[1]?.coverFile?.name).toBe('beta.webp');
|
||
});
|
||
|
||
it('keeps an already matched cover when the new selection has no partner', () => {
|
||
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
|
||
const next = attachCoverFiles(rows, [coverFile('other.png')]);
|
||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||
});
|
||
});
|
||
|
||
describe('validateTemplateUploadRows', () => {
|
||
it('accepts a complete batch', () => {
|
||
const rows = [
|
||
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
|
||
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
|
||
];
|
||
expect(validateTemplateUploadRows(rows)).toEqual({});
|
||
});
|
||
|
||
it('reports missing cover, duplicate id and invalid fields per row', () => {
|
||
const rows = [
|
||
row('alpha.zip'),
|
||
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
|
||
row('gamma.zip', {
|
||
coverFile: coverFile('gamma.png'),
|
||
runtime: 'docker',
|
||
templateVersion: 'Bad Version',
|
||
entry: '../escape.js',
|
||
title: ' ',
|
||
}),
|
||
];
|
||
const errors = validateTemplateUploadRows(rows);
|
||
expect(errors['alpha.zip']).toContain('缺少封面');
|
||
expect(errors.second).toContain('ID 在本批次内重复');
|
||
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
|
||
});
|
||
|
||
it('rejects a batch larger than the server limit', () => {
|
||
const rows = Array.from(
|
||
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
|
||
(_, index) =>
|
||
row(`template-${index}.zip`, {
|
||
coverFile: coverFile(`template-${index}.png`),
|
||
}),
|
||
);
|
||
const errors = validateTemplateUploadRows(rows);
|
||
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
|
||
expect(errors['template-0.zip']).toContain('单批最多上传');
|
||
});
|
||
});
|
||
|
||
describe('buildTemplateImportFormData', () => {
|
||
it('writes manifest field names and attaches zip / cover per row', () => {
|
||
const rows = [
|
||
row('alpha.zip', {
|
||
coverFile: coverFile('alpha.png'),
|
||
tags: '起步, 起步 2d',
|
||
title: ' Alpha ',
|
||
}),
|
||
row('beta.zip', { coverFile: coverFile('beta.png') }),
|
||
];
|
||
|
||
const form = buildTemplateImportFormData('a'.repeat(64), rows);
|
||
const manifest = JSON.parse(String(form.get('manifest')));
|
||
|
||
expect(manifest.expectedRevision).toBe('a'.repeat(64));
|
||
expect(manifest.templates).toHaveLength(2);
|
||
expect(manifest.templates[0]).toMatchObject({
|
||
id: 'alpha',
|
||
title: 'Alpha',
|
||
tags: ['起步', '2d'],
|
||
zipField: 'zip_0',
|
||
coverField: 'cover_0',
|
||
});
|
||
expect(manifest.templates[1]).toMatchObject({
|
||
id: 'beta',
|
||
zipField: 'zip_1',
|
||
coverField: 'cover_1',
|
||
});
|
||
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
|
||
expect((form.get('cover_1') as File).name).toBe('beta.png');
|
||
});
|
||
|
||
it('parses tags with dedupe and caps them at the server limit', () => {
|
||
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
|
||
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
|
||
',',
|
||
);
|
||
expect(parseTemplateTags(many)).toHaveLength(16);
|
||
});
|
||
});
|