Files
Genarrative/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts
T
kdletters 5db407c3dc
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Repository checks (push) Failing after 1m11s
Project CI / Frontend tests (push) Failing after 4m37s
Project CI / AI game creator shell web tests (push) Failing after 4m6s
Project CI / Native shell tests (push) Successful in 9m39s
Project CI / Backend tests (push) Successful in 10m12s
新增 AGC 模板库与由模板创建项目链路 (#396)
## 交付范围

- OSS `agc-dev` bucket 的 `templates/` 前缀作为 AGC 游戏模板库(**去掉 `agc/` 这一层**;`agc/` 继续只放客户端安装包与更新清单)。
- 模板正文改为 zip(zip 根 == AGC 项目根,如 `game/index.html`);清单补齐 `tags`、封面字段与包大小/摘要,`templateVersion` 用于判断是否需要重下。
- 客户端:Rust 模板库模块、模板库全屏页、首页模板推荐位、左侧导航入口。

## 客户端行为

- `fetch_game_template_library`:读取并校验清单(≤4 MiB),缓存到 `<app_data>/templates/index.json`;网络失败回退本机缓存并标注 `source=cache`。
- `download_game_template`:按清单下载 zip(≤512 MiB),校验字节数与 SHA-256 后解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成标记。
- `create_automatic_local_game_project_from_template`:先把模板文件铺进新项目目录,再走既有 `init_local_game_project_at` 补 `.agent` 清单与标准目录;失败删除半成品目录。
- 解压只接受普通文件与目录(拒绝绝对路径、`..`、盘符、符号链接),安装目录名由标识符白名单拼出;远端只信「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求。
- UI:搜索(空白分隔多关键词「与」)、标签/运行时筛选、仅看已下载、封面卡片与已下载徽标;「使用模板」在版本落后时先重下再建项。

## 文档与脚本

- 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;里程碑与实施计划见 `docs/project-memory/plans/`;`decision-log.md` 记录库路径、清单 schema、安装缓存目录与 CSP 约定。
- 发布脚本 `scripts/agc-template-library-publish.mjs`(`--dry-run` 校验 → 上传 → 回读校验)。
- 首页「灵感推荐」替换为模板库推荐位,删除本机灵感图目录与 `InspirationGallery.tsx`;`tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机用于加载封面。

## 验证

- `cargo test --bin genarrative-ai-game-creator-shell template_library`:9 passed;`--ignored` 真连检查 `fetches_the_live_template_library_index` 通过。
- `npx vitest run src/features/template-library`:9 passed。
- `npm run typecheck`、`cargo fmt --check`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 均通过。
- OSS 侧匿名复核:`templates/index.json`、各 `cover.png` 与 `template.zip` 均返回 200。

## 未做 / 待确认

- 本轮未做客户端点击级人工验收(下一步单独验证并把结果补进此 PR)。
- 模板封面目前是占位标题卡,正式封面由后续模板包提供。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/396
2026-09-17 17:48:06 +08:00

184 lines
5.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
collectGameTemplateRuntimes,
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
formatGameTemplateSize,
type GameTemplateEntry,
isTemplateLibraryFiltersEmpty,
needsTemplateDownload,
templateMatchesQuery,
templateRuntimeLabel,
toggleGameTemplateTag,
} from '../src/features/template-library/templateLibraryModel';
function template(
overrides: Partial<GameTemplateEntry> & Pick<GameTemplateEntry, 'id'>,
): GameTemplateEntry {
return {
title: '未命名模板',
summary: '',
tags: [],
runtime: 'html',
engine: 'phaser',
engineVersion: '4.2.1',
templateVersion: '1.0.0',
updatedAt: '2026-09-17T00:00:00Z',
entry: 'game/index.html',
zipUrl:
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/template.zip',
zipSizeBytes: 2048,
zipSha256: 'a'.repeat(64),
coverUrl:
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/cover.png',
coverWidth: 960,
coverHeight: 540,
installed: false,
installedVersion: null,
installedAtMillis: null,
...overrides,
};
}
const matchThree = template({
id: 'match-3',
title: '三消经营',
summary: '三消与模拟经营的融合模板',
tags: ['三消', '经营'],
engine: 'phaser',
installed: true,
installedVersion: '1.0.0',
});
const pixelFarm = template({
id: 'pixel-farm',
title: '像素农场',
summary: '像素风种植玩法',
tags: ['经营', '像素'],
engine: 'godot',
runtime: 'godot',
templateVersion: '2.0.0',
installed: true,
installedVersion: '1.0.0',
});
const spaceShooter = template({
id: 'space-shooter',
title: '太空射击',
summary: '纵版弹幕射击',
tags: ['射击'],
runtime: 'unity',
engine: 'unity',
installed: false,
});
const templates: GameTemplateEntry[] = [matchThree, pixelFarm, spaceShooter];
describe('templateMatchesQuery', () => {
it('matches title, summary, tags and engine case-insensitively', () => {
expect(templateMatchesQuery(matchThree, '三消')).toBe(true);
expect(templateMatchesQuery(matchThree, '经营')).toBe(true);
expect(templateMatchesQuery(matchThree, 'PHASER')).toBe(true);
expect(templateMatchesQuery(matchThree, '弹幕')).toBe(false);
});
it('requires every whitespace separated term to match', () => {
expect(templateMatchesQuery(pixelFarm, '像素 种植')).toBe(true);
expect(templateMatchesQuery(pixelFarm, '像素 弹幕')).toBe(false);
expect(templateMatchesQuery(pixelFarm, ' ')).toBe(true);
});
});
describe('filterGameTemplates', () => {
it('filters by tag, runtime, query and installed state together', () => {
expect(
filterGameTemplates(templates, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
tags: ['经营'],
}).map((entry) => entry.id),
).toEqual(['match-3', 'pixel-farm']);
expect(
filterGameTemplates(templates, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
runtime: 'godot',
}).map((entry) => entry.id),
).toEqual(['pixel-farm']);
expect(
filterGameTemplates(templates, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
installedOnly: true,
query: '经营',
tags: ['像素'],
}).map((entry) => entry.id),
).toEqual(['pixel-farm']);
});
it('returns everything when no filter is active', () => {
expect(
filterGameTemplates(templates, EMPTY_TEMPLATE_LIBRARY_FILTERS),
).toHaveLength(3);
expect(isTemplateLibraryFiltersEmpty(EMPTY_TEMPLATE_LIBRARY_FILTERS)).toBe(
true,
);
expect(
isTemplateLibraryFiltersEmpty({
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
query: ' x ',
}),
).toBe(false);
});
});
describe('tag and runtime options', () => {
it('orders tags by frequency and drops blanks', () => {
const withBlank = [
...templates,
template({ id: 'blank-tag', tags: ['', ' ', '经营'] }),
];
expect(collectGameTemplateTags(withBlank)).toEqual([
'经营',
'三消',
'射击',
'像素',
]);
});
it('collects distinct runtimes and labels them', () => {
expect(collectGameTemplateRuntimes(templates)).toEqual([
'godot',
'html',
'unity',
]);
expect(templateRuntimeLabel('html')).toBe('网页');
expect(templateRuntimeLabel('cocos')).toBe('Cocos');
expect(templateRuntimeLabel('')).toBe('未标注运行时');
expect(templateRuntimeLabel('custom-engine')).toBe('custom-engine');
});
it('toggles tags without mutating the previous filters', () => {
const next = toggleGameTemplateTag(EMPTY_TEMPLATE_LIBRARY_FILTERS, '经营');
expect(next.tags).toEqual(['经营']);
expect(toggleGameTemplateTag(next, '经营').tags).toEqual([]);
expect(EMPTY_TEMPLATE_LIBRARY_FILTERS.tags).toEqual([]);
});
});
describe('needsTemplateDownload', () => {
it('requires a download when missing or when the installed version is stale', () => {
expect(needsTemplateDownload(matchThree)).toBe(false);
expect(needsTemplateDownload(pixelFarm)).toBe(true);
expect(needsTemplateDownload(spaceShooter)).toBe(true);
});
});
describe('formatGameTemplateSize', () => {
it('formats bytes, kilobytes and megabytes', () => {
expect(formatGameTemplateSize(0)).toBe('--');
expect(formatGameTemplateSize(512)).toBe('512 B');
expect(formatGameTemplateSize(2048)).toBe('2.0 KB');
expect(formatGameTemplateSize(5 * 1024 * 1024)).toBe('5.0 MB');
});
});