补齐模板库页面单测与真连建项目检查
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m50s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m2s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m36s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Failing after 4m41s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m48s
Project CI / Native shell tests (pull_request) Successful in 9m39s

- 前端单测迁到 apps/ai-game-creator-shell/tests/,与根 vitest include 一致;新增模板库页面与首页推荐位 8 项渲染/交互测试(卡片封面、标签、已下载徽标、搜索、筛选、下载与使用模板、空态与错误态)
- 模板库的运行时与标签筛选补 aria-label,避免同名按钮歧义
- 真连检查扩展为「读线上清单 → 下载安装线上模板 → 据此建项目」的完整链路
- 同步技术方案、里程碑与实施计划的验证命令与验收口径
This commit is contained in:
kdletters
2026-09-17 11:37:29 +08:00
parent 7eaaa1a499
commit 46d395763d
7 changed files with 328 additions and 5 deletions
@@ -1072,5 +1072,21 @@ mod tests {
assert!(project_root.join("game/index.html").is_file());
assert!(project_root.join("game/main.js").is_file());
assert!(project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).is_file());
// 同一条链路继续建项目:线上模板 → 本机安装 → 新项目目录。
let projects_root = unique_projects_root();
let created = create_project_from_installed_template_at(
&projects_root,
project_root,
Some("线上模板项目"),
false,
)
.expect("create project from live template");
assert_eq!(created.manifest.name, "线上模板项目");
let created_root = Path::new(&created.project_path);
assert!(created_root.join("game/index.html").is_file());
assert!(created_root.join("game/main.js").is_file());
assert!(created_root.join(".agent/manifest.json").is_file());
fs::remove_dir_all(&projects_root).ok();
}
}
@@ -255,6 +255,7 @@ export default function TemplateLibraryView({
filters.runtime === runtime ? activeChipClass : chipClass
}
aria-pressed={filters.runtime === runtime}
aria-label={`运行时筛选 ${templateRuntimeLabel(runtime)}`}
onClick={() => selectRuntime(runtime)}
>
{templateRuntimeLabel(runtime)}
@@ -275,6 +276,7 @@ export default function TemplateLibraryView({
filters.tags.includes(tag) ? activeChipClass : chipClass
}
aria-pressed={filters.tags.includes(tag)}
aria-label={`标签筛选 ${tag}`}
onClick={() => toggleTag(tag)}
>
{tag}
@@ -12,7 +12,7 @@ import {
templateMatchesQuery,
templateRuntimeLabel,
toggleGameTemplateTag,
} from './templateLibraryModel';
} from '../src/features/template-library/templateLibraryModel';
function template(
overrides: Partial<GameTemplateEntry> & Pick<GameTemplateEntry, 'id'>,
@@ -0,0 +1,300 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type {
GameTemplateEntry,
TemplateLibraryFilters,
} from '../src/features/template-library/templateLibraryModel';
import type { TemplateLibraryController } from '../src/features/template-library/useTemplateLibrary';
import TemplateRecommendations from '../src/view/home/TemplateRecommendations';
import TemplateLibraryView from '../src/view/template-library';
const OSS_BASE = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com';
function template(
overrides: Partial<GameTemplateEntry> & Pick<GameTemplateEntry, 'id'>,
): GameTemplateEntry {
return {
title: '未命名模板',
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: `${OSS_BASE}/templates/v1/${overrides.id}/template.zip`,
zipSizeBytes: 2048,
zipSha256: 'a'.repeat(64),
coverUrl: `${OSS_BASE}/templates/v1/${overrides.id}/cover.svg`,
coverWidth: 960,
coverHeight: 540,
installed: false,
installedVersion: null,
installedAtMillis: null,
...overrides,
};
}
const blankWeb = template({
id: 'blank-web',
title: '空白网页工程',
summary: '零依赖最小网页工程',
tags: ['空白', '网页'],
engine: 'none',
engineVersion: '',
installed: true,
installedVersion: '0.1.0',
installedAtMillis: 1789000000000,
});
const blankCanvas = template({
id: 'blank-2d-canvas',
title: '空白二维画布工程',
tags: ['空白', '2d', 'canvas'],
engine: 'canvas',
engineVersion: '',
});
const templates = [blankCanvas, blankWeb];
function controller(
overrides: Partial<TemplateLibraryController> = {},
): TemplateLibraryController {
const filters: TemplateLibraryFilters = {
query: '',
tags: [],
runtime: '',
installedOnly: false,
};
return {
snapshot: null,
status: 'ready',
error: '',
notice: '',
templates,
visibleTemplates: templates,
tagOptions: ['空白', '2d', 'canvas', '网页'],
runtimeOptions: ['html'],
installedCount: 1,
filters,
filtersActive: false,
setQuery: vi.fn(),
selectRuntime: vi.fn(),
toggleTag: vi.fn(),
setInstalledOnly: vi.fn(),
clearFilters: vi.fn(),
busyTemplateId: null,
busyKind: null,
refresh: vi.fn(),
downloadTemplate: vi.fn(async () => undefined),
createProjectFromTemplate: vi.fn(async () => undefined),
clearNotice: vi.fn(),
...overrides,
} as unknown as TemplateLibraryController;
}
function cardFor(title: string): HTMLElement {
const heading = screen.getByText(title);
const card = heading.closest('article');
if (!card) throw new Error(`找不到卡片:${title}`);
return card;
}
describe('TemplateLibraryView', () => {
it('renders a card per template with cover, meta, tags and installed badge', () => {
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
expect(screen.getByRole('heading', { name: '模板库' })).toBeTruthy();
expect(screen.getByText('共 2 个模板 · 已下载 1 个')).toBeTruthy();
const blankWebCard = cardFor('空白网页工程');
const cover = blankWebCard.querySelector('img');
expect(cover?.getAttribute('src')).toBe(
`${OSS_BASE}/templates/v1/blank-web/cover.svg`,
);
expect(blankWebCard.textContent).toContain('已下载');
expect(blankWebCard.textContent).toContain('网页 · none · v0.1.0 · 2.0 KB');
expect(blankWebCard.textContent).toContain('空白');
expect(blankWebCard.textContent).toContain('零依赖最小网页工程');
const blankCanvasCard = cardFor('空白二维画布工程');
expect(blankCanvasCard.textContent).not.toContain('已下载');
expect(blankCanvasCard.querySelector('img')?.getAttribute('src')).toBe(
`${OSS_BASE}/templates/v1/blank-2d-canvas/cover.svg`,
);
});
it('routes search, tag, runtime and installed-only controls through the controller', () => {
const setQuery = vi.fn();
const toggleTag = vi.fn();
const selectRuntime = vi.fn();
const setInstalledOnly = vi.fn();
const clearFilters = vi.fn();
render(
<TemplateLibraryView
controller={controller({
setQuery,
toggleTag,
selectRuntime,
setInstalledOnly,
clearFilters,
filtersActive: true,
})}
onBack={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText('搜索模板'), {
target: { value: '空白 网页' },
});
expect(setQuery).toHaveBeenCalledWith('空白 网页');
fireEvent.click(screen.getByRole('button', { name: '标签筛选 canvas' }));
expect(toggleTag).toHaveBeenCalledWith('canvas');
fireEvent.click(screen.getByRole('button', { name: '运行时筛选 网页' }));
expect(selectRuntime).toHaveBeenCalledWith('html');
fireEvent.click(screen.getByRole('button', { name: '仅看已下载' }));
expect(setInstalledOnly).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '清除筛选' }));
expect(clearFilters).toHaveBeenCalled();
});
it('starts a download and a template project from the card actions', () => {
const downloadTemplate = vi.fn(async () => undefined);
const createProjectFromTemplate = vi.fn(async () => undefined);
render(
<TemplateLibraryView
controller={controller({ downloadTemplate, createProjectFromTemplate })}
onBack={() => {}}
/>,
);
fireEvent.click(
within(cardFor('空白二维画布工程')).getByRole('button', { name: /下载/ }),
);
expect(downloadTemplate).toHaveBeenCalledWith(blankCanvas);
fireEvent.click(
within(cardFor('空白二维画布工程')).getByRole('button', {
name: /使用模板/,
}),
);
expect(createProjectFromTemplate).toHaveBeenCalledWith(blankCanvas);
});
it('disables card actions while a template is busy', () => {
render(
<TemplateLibraryView
controller={controller({
busyTemplateId: 'blank-2d-canvas',
busyKind: 'create',
})}
onBack={() => {}}
/>,
);
const busyCard = cardFor('空白二维画布工程');
const buttons = Array.from(busyCard.querySelectorAll('button'));
expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe(
true,
);
expect(busyCard.textContent).toContain('正在创建项目');
expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目');
});
it('shows empty, no-match, error and notice states', () => {
const { unmount } = render(
<TemplateLibraryView
controller={controller({
templates: [],
visibleTemplates: [],
installedCount: 0,
})}
onBack={() => {}}
/>,
);
expect(screen.getByText('模板库暂时还没有可用的模板。')).toBeTruthy();
unmount();
const { unmount: unmountNoMatch } = render(
<TemplateLibraryView
controller={controller({ visibleTemplates: [], filtersActive: true })}
onBack={() => {}}
/>,
);
expect(screen.getByText('没有符合当前筛选的模板')).toBeTruthy();
unmountNoMatch();
render(
<TemplateLibraryView
controller={controller({
error: '模板库返回 HTTP 503',
notice: '远端清单暂时读不到,当前展示本机缓存',
})}
onBack={() => {}}
/>,
);
expect(screen.getByRole('alert').textContent).toContain(
'模板库返回 HTTP 503',
);
expect(
screen.getByText('远端清单暂时读不到,当前展示本机缓存'),
).toBeTruthy();
});
});
describe('TemplateRecommendations', () => {
it('renders the recommended templates and opens the library', () => {
const onOpenLibrary = vi.fn();
render(
<TemplateRecommendations
templates={templates}
loading={false}
error=""
onOpenLibrary={onOpenLibrary}
/>,
);
const recommendation = screen.getByRole('button', {
name: '查看模板 空白网页工程',
});
expect(recommendation.querySelector('img')?.getAttribute('src')).toBe(
`${OSS_BASE}/templates/v1/blank-web/cover.svg`,
);
expect(recommendation.textContent).toContain('已下载');
fireEvent.click(recommendation);
expect(onOpenLibrary).toHaveBeenCalled();
});
it('falls back to an empty state with a library entry', () => {
const onOpenLibrary = vi.fn();
render(
<TemplateRecommendations
templates={[]}
loading={false}
error="需要在陶泥儿客户端内运行"
onOpenLibrary={onOpenLibrary}
/>,
);
expect(screen.getByText('需要在陶泥儿客户端内运行')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开模板库' }));
expect(onOpenLibrary).toHaveBeenCalled();
});
it('renders a loading state before the first snapshot arrives', () => {
render(
<TemplateRecommendations
templates={[]}
loading
error=""
onOpenLibrary={() => {}}
/>,
);
expect(screen.getByText('正在读取模板库…')).toBeTruthy();
});
});
@@ -42,8 +42,9 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】AGC模板库客户端
```bash
cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored
cd apps/ai-game-creator-shell && npx tsc -p tsconfig.json --noEmit
cd apps/ai-game-creator-shell && npx vitest run src/features/template-library
npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
node scripts/agc-template-library-publish.mjs --source <dir> --dry-run
npm run check:encoding
node scripts/check-doc-index.mjs
@@ -31,7 +31,7 @@ AGC 客户端能读取公共 OSS 上的游戏模板库,并把「浏览 → 筛
4. `create_automatic_local_game_project_from_template` 建出的项目同时具备模板文件、`.agent` 清单与标准目录;失败时不留项目目录。
5. 模板库页可按关键词、标签、运行时与「仅看已下载」筛选;卡片显示封面与已下载徽标;「使用模板」在版本落后时先重下再建项。
6. 首页推荐位展示模板库内容并可进入模板库页;左侧导航有模板库入口且为独立全屏页。
7. 定向 Rust 单测 8、前端模型单测 9 项、`tsc` 类型检查、`npm run check:encoding``git diff --check` 全部通过。
7. 定向 Rust 单测 9(含线上清单 fixture)、前端模型 9 项 + 页面/推荐位 8 项、`tsc` 类型检查、`npm run check:encoding``git diff --check` 全部通过;可选真连检查能读线上清单、下载安装线上模板并据此建项目
## 依赖
@@ -76,8 +76,10 @@ templates/
```bash
# 模板库单测(清单校验、键安全、解压路径逃逸、安装与建项目)
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library
# 前端模型单测
cd apps/ai-game-creator-shell && npx vitest run src/features/template-library
# 模板库真连检查(可选,需要网络):读线上清单、下载安装线上模板包并据此建项目
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell template_library -- --ignored
# 前端模型与页面单测(AGC 测试统一在 apps/ai-game-creator-shell/tests/
npx vitest run apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
# 类型检查 / 编码 / 空白
cd apps/ai-game-creator-shell && npm run typecheck
npm run check:encoding
@@ -88,6 +90,8 @@ curl -s https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/index.json
手工验收:打开模板库 → 搜索与筛选 → 下载(出现「已下载」徽标)→ 「使用模板」→ 进入项目工作台且项目里已有模板文件。
运行态核验(客户端真的拉过清单时):本机缓存 `<app_data>/templates/index.json` 与线上 `templates/index.json` 逐字节一致;`<app_data>/templates/installed/<id>/<version>/installed.json` 出现即表示该模板已下载完成。
## 失败与回退
- 清单读不到且没有本机缓存:模板库页显示错误与重试,首页推荐位显示「模板库暂时没有可用的模板」。