Files
Genarrative/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx
T
kdletters e072c66ce9
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
修复 AGC 模板库筛选与卡片状态的表现缺陷
- 模板库筛选区改用共享筛选条与标签 chip:选中为实心品牌填充 + 反白文字,未选为浅底描边
- 未选中悬停不再借用品牌色(品牌色专属已选中),状态阶梯固定为静止 / 悬停 / 按下 / 选中
- 新增 getPlatformCategoryChipClassName 收敛筛选 chip 类名口径,模板库、资源画布、参考图弹窗与平台 Web 端共用
- PlatformSegmentedTabs 增加 accent 选中口径,与筛选 chip 共用 --platform-chip-* 语义色
- 修复卡片文字区按 grid auto 行排版导致的标题 / 简介 / 标签被裁:行高契约拆为分项常量,文字区固定 174
- 修复忙状态下动作行塞入第三个元素导致的按钮文案换行顶出卡片:忙状态写在触发它的按钮上
- 修复经典滚动条占宽导致的卡片区横向滚动条:按竖滚动条预留列宽后再算列宽行高
- 焦点环由 15% 透明度改为实心色并用 outline 绘制,选中项聚焦不再被状态投影盖掉
- 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标
- 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试
- 同步技术方案、踩坑记录与决策记录的口径
2026-09-23 16:51:15 +08:00

531 lines
18 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 {
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,
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('pins every card text row to the height the grid contract budgets for it', () => {
// 回归点:文字区若是 `grid` 的 auto 行,行高会按 max-content 算成「一行」,
// 标题 / 简介 / 标签会被逐行截断(现场表现为标题文字被切掉)。这里钉住
// 卡片每一行的固定高度,改动必须同时改 `TEMPLATE_CARD_*_HEIGHT` 那组常量。
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
const card = cardFor('空白网页工程');
const textBlock = card.children[1] as HTMLElement;
const rows = Array.from(textBlock.children) as HTMLElement[];
expect(textBlock.className).toContain('flex-col');
expect(rows.map((row) => row.className)).toEqual([
expect.stringContaining('h-5'),
expect.stringContaining('h-4'),
expect.stringContaining('h-8'),
expect.stringContaining('h-5.5'),
expect.stringContaining('h-7'),
]);
// 每行都不参与压缩,否则 flex 会把文字压回去。
rows.forEach((row) => expect(row.className).toContain('shrink-0'));
});
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('hides a cover that failed to load instead of showing a broken image', () => {
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
const cover = cardFor('空白网页工程').querySelector(
'img',
) as HTMLImageElement;
expect(cover.style.visibility).toBe('');
fireEvent.error(cover);
expect(cover.style.visibility).toBe('hidden');
});
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'));
// 忙状态写在触发它的按钮上(文案就地变成「创建中」),动作行里不额外塞第三个元素,
// 否则最小卡宽(250px)下两个按钮的文案会被挤成两行、顶出卡片。
expect(buttons).toHaveLength(2);
expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe(
true,
);
expect(busyCard.textContent).toContain('创建中');
expect(busyCard.textContent).not.toContain('使用模板');
expect(cardFor('空白网页工程').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,
})}
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();
});
});