Files
Genarrative/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
T
kdletters 066d0fe6f9
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m17s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 5m33s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m29s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m40s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m2s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 8m46s
模板库卡片列表接入虚拟滚动
- 用 workspace 已有的 react-window@1.8.11 的 FixedSizeGrid:只渲染可视行 + 2 行 overscan,1000 条不再一次性挂 1000 个卡片节点
- 新增 templateLibraryGrid.ts 纯函数布局契约(列数/列宽/行高/行数/按行切分),单测覆盖
- 卡片抽成 memo 化的 TemplateCard;筛选条件变化时把滚动位置复位到顶部
- 筛选区(运行时/标签)改为可独立滚动区块,标签膨胀不再挤压卡片区
- 页面回归改用固定视口:断言 1000 条只渲染 ≤40 张卡、滚动高度按 250 行计算
- 技术方案补虚拟滚动契约说明
2026-09-17 16:36:04 +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();
});
});