Files
Genarrative/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
T
kdletters 623e007fae
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m36s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m42s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / AI game creator shell Rust crates (push) Successful in 4m15s
Project CI / Repository checks (push) Successful in 4m45s
Project CI / Frontend tests (push) Successful in 7m47s
Project CI / Native shell tests (push) Successful in 9m33s
Project CI / Backend tests (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 4m38s
完善客户端发布渠道并接入模板库灰度
区分发布渠道与系统,支持 dev、release 和自定义渠道
允许网站通过服务端配置选择客户端下载检测渠道
接入模板库灰度权限并阻断退出和切号后的异步操作
补齐发布、下载、灰度与会话竞态测试及当前规范
2026-09-20 12:12:49 +08:00

496 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 {
enabled: true,
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();
});
});