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

495 lines
16 KiB
TypeScript
Raw 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.
// @vitest-environment jsdom
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid';
import type {
GameTemplateEntry,
TemplateLibraryFilters,
} from '../src/features/template-library/templateLibraryModel';
import {
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
} 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;
}
// 虚拟列表需要可测量的视口:jsdom 没有布局,这里给网格容器固定尺寸并补 ResizeObserver/scrollTo。
const GRID_VIEWPORT = { width: 1200, height: 800 };
beforeAll(() => {
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function getBoundingClientRect() {
const element = this as HTMLElement;
if (element.dataset?.templateGridViewport === 'true') {
return {
width: GRID_VIEWPORT.width,
height: GRID_VIEWPORT.height,
top: 0,
left: 0,
right: GRID_VIEWPORT.width,
bottom: GRID_VIEWPORT.height,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
return originalGetBoundingClientRect.call(this);
};
if (typeof Element.prototype.scrollTo !== 'function') {
Element.prototype.scrollTo = () => undefined;
}
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
});
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('零依赖最小网页工程');
// 已下载且版本一致:不再显示下载入口,只留「使用模板」。
expect(
within(blankWebCard).queryByRole('button', { name: /下载/ }),
).toBeNull();
expect(
within(blankWebCard).getByRole('button', { name: /使用模板/ }),
).toBeTruthy();
const blankCanvasCard = cardFor('空白二维画布工程');
expect(blankCanvasCard.textContent).not.toContain('已下载');
expect(blankCanvasCard.querySelector('img')?.getAttribute('src')).toBe(
`${OSS_BASE}/templates/v1/blank-2d-canvas/cover.svg`,
);
expect(
within(blankCanvasCard).getByRole('button', { name: /下载/ }),
).toBeTruthy();
});
it('keeps the launcher theme contract and owns its own scroll viewport', () => {
// 回归点:`.launcher-main` 只给带 platform-theme 的直接子元素 height:100%
// 卡片列表改由虚拟网格自己的视口滚动(整页不再随模板数量变长)。
const { container } = render(
<TemplateLibraryView controller={controller()} onBack={() => {}} />,
);
const page = container.querySelector('section[aria-label="模板库"]');
expect(page?.className).toContain('platform-theme');
const viewport = container.querySelector(
'[data-template-grid-viewport="true"]',
);
expect(viewport).not.toBeNull();
expect(viewport?.querySelector('article')).not.toBeNull();
});
it('offers 更新 instead of 下载 when the installed version is stale', () => {
const stale = template({
id: 'blank-web',
title: '空白网页工程',
summary: '零依赖最小网页工程',
tags: ['空白', '网页'],
installed: true,
installedVersion: '0.0.9',
installedAtMillis: 1789000000000,
});
render(
<TemplateLibraryView
controller={controller({
templates: [stale],
visibleTemplates: [stale],
})}
onBack={() => {}}
/>,
);
const card = cardFor('空白网页工程');
expect(within(card).getByRole('button', { name: /更新/ })).toBeTruthy();
expect(within(card).queryByRole('button', { name: /^下载/ })).toBeNull();
});
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();
});
it('renders process notices as a floating toast outside the page and auto-dismisses it', () => {
vi.useFakeTimers();
const clearNotice = vi.fn();
try {
const { container } = render(
<TemplateLibraryView
controller={controller({
notice: '已下载模板「空白网页工程」',
clearNotice,
})}
onBack={() => {}}
/>,
);
const toast = document.body.querySelector(
'[data-template-library-toast="true"]',
);
expect(toast).not.toBeNull();
expect(toast?.textContent).toContain('已下载模板「空白网页工程」');
expect(toast?.querySelector('[role="status"]')?.textContent).toContain(
'已下载模板「空白网页工程」',
);
// 提示不再占用页面内位置。
expect(
container.querySelector('[data-template-library-toast]'),
).toBeNull();
vi.advanceTimersByTime(2600);
expect(clearNotice).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
describe('大库量渲染(1000 条假数据)', () => {
const bulk = Array.from({ length: 1000 }, (_, index) =>
template({
id: `bulk-${index}`,
title: `批量模板 ${index}`,
summary: '压测条目',
tags: ['起步工程', `批次-${String(index % 20).padStart(2, '0')}`],
installed: index % 3 === 0,
installedVersion: index % 3 === 0 ? '0.1.0' : null,
}),
);
it('virtualizes a 1000 entries library instead of rendering every card', () => {
const { container } = render(
<TemplateLibraryView
controller={controller({
templates: bulk,
visibleTemplates: bulk,
installedCount: bulk.filter((entry) => entry.installed).length,
tagOptions: collectGameTemplateTags(bulk),
})}
onBack={() => {}}
/>,
);
// 虚拟列表只渲染可视区域(1200×800 视口 → 4 列 × 约 3 行 + 2 行 overscan)。
const renderedCards = container.querySelectorAll('article').length;
expect(renderedCards).toBeGreaterThan(0);
expect(renderedCards).toBeLessThanOrEqual(40);
expect(screen.getByText('共 1000 个模板 · 已下载 334 个')).toBeTruthy();
// 滚动高度仍按全部行数计算。
const layout = computeTemplateGridLayout({
containerWidth: GRID_VIEWPORT.width,
itemCount: bulk.length,
});
expect(layout.columnCount).toBe(4);
expect(layout.rowCount).toBe(250);
const totalHeight = `${250 * layout.rowHeight}px`;
const hasSpacer = Array.from(container.querySelectorAll('div')).some(
(element) => (element as HTMLElement).style.height === totalHeight,
);
expect(hasSpacer).toBe(true);
// 标签筛选条会随库量膨胀,这里先记录当前聚合出来的规模(1000 条 × 批次标签)。
const tagButtons = screen
.getAllByRole('button')
.filter((button) =>
button.getAttribute('aria-label')?.startsWith('标签筛选'),
);
expect(tagButtons.length).toBeGreaterThan(20);
// 纯前端筛选在大库量下仍然是 O(n) 的一遍过滤,数量与已安装态自洽。
const installedOnly = filterGameTemplates(bulk, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
installedOnly: true,
});
expect(installedOnly).toHaveLength(334);
expect(
filterGameTemplates(bulk, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
tags: ['批次-07'],
}),
).toHaveLength(50);
expect(
filterGameTemplates(bulk, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
query: '批量模板 999',
}).map((entry) => entry.id),
).toEqual(['bulk-999']);
});
});
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();
});
});