Files
Genarrative/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts
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

109 lines
3.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
buildTemplateRows,
computeTemplateGridColumns,
computeTemplateGridLayout,
computeTemplateRowHeight,
TEMPLATE_CARD_GAP,
TEMPLATE_CARD_MIN_WIDTH,
TEMPLATE_CARD_TEXT_HEIGHT,
} from '../src/features/template-library/templateLibraryGrid';
import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
function entry(id: string): GameTemplateEntry {
return {
id,
title: id,
summary: '',
tags: [],
runtime: 'html',
engine: 'phaser',
engineVersion: '4.2.1',
templateVersion: '0.1.0',
updatedAt: '2026-09-17T00:00:00Z',
entry: 'game/index.html',
zipUrl: `https://oss.example/templates/v1/${id}/template.zip`,
zipSizeBytes: 1024,
zipSha256: 'a'.repeat(64),
coverUrl: `https://oss.example/templates/v1/${id}/cover.svg`,
coverWidth: 960,
coverHeight: 540,
installed: false,
installedVersion: null,
installedAtMillis: null,
};
}
describe('computeTemplateGridColumns', () => {
it('fits as many min-width columns as the container allows', () => {
expect(computeTemplateGridColumns(0)).toBe(1);
expect(computeTemplateGridColumns(200)).toBe(1);
expect(computeTemplateGridColumns(TEMPLATE_CARD_MIN_WIDTH)).toBe(1);
// 两列边界:2*250 + 14 = 514
const twoColumnWidth = 2 * TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP;
expect(computeTemplateGridColumns(twoColumnWidth)).toBe(2);
expect(computeTemplateGridColumns(twoColumnWidth - 1)).toBe(1);
// 1200px4 列((1200+14)/(250+14) = 4.59
expect(computeTemplateGridColumns(1200)).toBe(4);
});
});
describe('computeTemplateRowHeight', () => {
it('keeps cover ratio + fixed text block', () => {
// 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
expect(computeTemplateRowHeight(300)).toBe(
161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
);
// 极窄时按最小卡片宽度兜底,避免行高被压成 0
expect(computeTemplateRowHeight(10)).toBeGreaterThan(
TEMPLATE_CARD_TEXT_HEIGHT,
);
});
});
describe('computeTemplateGridLayout', () => {
it('derives columns, row height and row count for a big library', () => {
const layout = computeTemplateGridLayout({
containerWidth: 1200,
itemCount: 1000,
});
expect(layout.columnCount).toBe(4);
expect(layout.columnWidth).toBe(300);
expect(layout.rowHeight).toBe(
161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
);
expect(layout.rowCount).toBe(250);
// 虚拟列表只渲染可视行,滚动高度仍由总行数决定
expect(layout.rowCount * layout.rowHeight).toBeGreaterThan(80000);
});
it('handles inline and empty libraries', () => {
expect(
computeTemplateGridLayout({ containerWidth: 0, itemCount: 5 }),
).toEqual({
columnCount: 1,
columnWidth: 1,
rowHeight: computeTemplateRowHeight(1),
rowCount: 5,
});
expect(
computeTemplateGridLayout({ containerWidth: 1200, itemCount: 0 })
.rowCount,
).toBe(0);
});
});
describe('buildTemplateRows', () => {
it('chunks entries per row and pads the tail with nulls', () => {
const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
expect(rows).toHaveLength(2);
expect(rows[0]?.map((item) => item?.id)).toEqual(['a', 'b']);
expect(rows[1]?.map((item) => item?.id ?? null)).toEqual(['c', null]);
});
it('returns no rows for an invalid column count', () => {
expect(buildTemplateRows([entry('a')], 0)).toEqual([]);
});
});