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
- 模板库筛选区改用共享筛选条与标签 chip:选中为实心品牌填充 + 反白文字,未选为浅底描边 - 未选中悬停不再借用品牌色(品牌色专属已选中),状态阶梯固定为静止 / 悬停 / 按下 / 选中 - 新增 getPlatformCategoryChipClassName 收敛筛选 chip 类名口径,模板库、资源画布、参考图弹窗与平台 Web 端共用 - PlatformSegmentedTabs 增加 accent 选中口径,与筛选 chip 共用 --platform-chip-* 语义色 - 修复卡片文字区按 grid auto 行排版导致的标题 / 简介 / 标签被裁:行高契约拆为分项常量,文字区固定 174 - 修复忙状态下动作行塞入第三个元素导致的按钮文案换行顶出卡片:忙状态写在触发它的按钮上 - 修复经典滚动条占宽导致的卡片区横向滚动条:按竖滚动条预留列宽后再算列宽行高 - 焦点环由 15% 透明度改为实心色并用 outline 绘制,选中项聚焦不再被状态投影盖掉 - 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标 - 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试 - 同步技术方案、踩坑记录与决策记录的口径
531 lines
18 KiB
TypeScript
531 lines
18 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 {
|
||
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();
|
||
});
|
||
});
|