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
## 交付范围 - 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
495 lines
16 KiB
TypeScript
495 lines
16 KiB
TypeScript
// @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();
|
||
});
|
||
});
|