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 绘制,选中项聚焦不再被状态投影盖掉 - 封面加载失败改为隐藏图片留中性底色,不再出现浏览器裂图图标 - 补充状态对比度、行高契约、滚动条预留、封面兜底与忙状态文案的回归测试 - 同步技术方案、踩坑记录与决策记录的口径
183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
buildTemplateRows,
|
||
computeTemplateGridColumns,
|
||
computeTemplateGridLayout,
|
||
computeTemplateGridLayoutWithScrollbar,
|
||
computeTemplateRowHeight,
|
||
TEMPLATE_CARD_ACTIONS_HEIGHT,
|
||
TEMPLATE_CARD_GAP,
|
||
TEMPLATE_CARD_META_HEIGHT,
|
||
TEMPLATE_CARD_MIN_WIDTH,
|
||
TEMPLATE_CARD_SUMMARY_HEIGHT,
|
||
TEMPLATE_CARD_TAGS_HEIGHT,
|
||
TEMPLATE_CARD_TEXT_GAP,
|
||
TEMPLATE_CARD_TEXT_HEIGHT,
|
||
TEMPLATE_CARD_TEXT_PADDING,
|
||
TEMPLATE_CARD_TITLE_HEIGHT,
|
||
} from '../src/features/template-library/templateLibraryGrid';
|
||
import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
|
||
|
||
function entry(id: string): GameTemplateEntry {
|
||
return {
|
||
id,
|
||
title: id,
|
||
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: `https://oss.example/templates/v1/${id}/template.zip`,
|
||
zipSizeBytes: 1024,
|
||
zipSha256: 'a'.repeat(64),
|
||
coverUrl: `https://oss.example/templates/v1/${id}/cover.svg`,
|
||
coverWidth: 960,
|
||
coverHeight: 540,
|
||
installed: false,
|
||
installedVersion: null,
|
||
installedAtMillis: null,
|
||
};
|
||
}
|
||
|
||
describe('computeTemplateGridColumns', () => {
|
||
it('fits as many min-width columns as the container allows', () => {
|
||
expect(computeTemplateGridColumns(0)).toBe(1);
|
||
expect(computeTemplateGridColumns(200)).toBe(1);
|
||
expect(computeTemplateGridColumns(TEMPLATE_CARD_MIN_WIDTH)).toBe(1);
|
||
// 两列边界:2*250 + 14 = 514
|
||
const twoColumnWidth = 2 * TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP;
|
||
expect(computeTemplateGridColumns(twoColumnWidth)).toBe(2);
|
||
expect(computeTemplateGridColumns(twoColumnWidth - 1)).toBe(1);
|
||
// 1200px:4 列((1200+14)/(250+14) = 4.59)
|
||
expect(computeTemplateGridColumns(1200)).toBe(4);
|
||
});
|
||
});
|
||
|
||
describe('computeTemplateRowHeight', () => {
|
||
it('budgets the same height the card rows actually need', () => {
|
||
// 这些数字对应 TemplateCard 的 `h-5`/`h-4`/`h-8`/`h-5.5`/`h-7` 与 `p-3`/`gap-2`:
|
||
// 行高契约比真实内容小,被截断的就是卡片里的标题与简介。
|
||
expect(TEMPLATE_CARD_TITLE_HEIGHT).toBe(20);
|
||
expect(TEMPLATE_CARD_META_HEIGHT).toBe(16);
|
||
expect(TEMPLATE_CARD_SUMMARY_HEIGHT).toBe(32);
|
||
expect(TEMPLATE_CARD_TAGS_HEIGHT).toBe(22);
|
||
expect(TEMPLATE_CARD_ACTIONS_HEIGHT).toBe(28);
|
||
expect(TEMPLATE_CARD_TEXT_PADDING).toBe(12);
|
||
expect(TEMPLATE_CARD_TEXT_GAP).toBe(8);
|
||
expect(TEMPLATE_CARD_TEXT_HEIGHT).toBe(174);
|
||
});
|
||
|
||
it('keeps cover ratio + fixed text block', () => {
|
||
// 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
|
||
expect(computeTemplateRowHeight(300)).toBe(
|
||
161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
|
||
);
|
||
// 极窄时按最小卡片宽度兜底,避免行高被压成 0
|
||
expect(computeTemplateRowHeight(10)).toBeGreaterThan(
|
||
TEMPLATE_CARD_TEXT_HEIGHT,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('computeTemplateGridLayout', () => {
|
||
it('derives columns, row height and row count for a big library', () => {
|
||
const layout = computeTemplateGridLayout({
|
||
containerWidth: 1200,
|
||
itemCount: 1000,
|
||
});
|
||
expect(layout.columnCount).toBe(4);
|
||
expect(layout.columnWidth).toBe(300);
|
||
expect(layout.rowHeight).toBe(
|
||
161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
|
||
);
|
||
expect(layout.rowCount).toBe(250);
|
||
// 虚拟列表只渲染可视行,滚动高度仍由总行数决定
|
||
expect(layout.rowCount * layout.rowHeight).toBeGreaterThan(80000);
|
||
});
|
||
|
||
it('handles inline and empty libraries', () => {
|
||
expect(
|
||
computeTemplateGridLayout({ containerWidth: 0, itemCount: 5 }),
|
||
).toEqual({
|
||
columnCount: 1,
|
||
columnWidth: 1,
|
||
rowHeight: computeTemplateRowHeight(1),
|
||
rowCount: 5,
|
||
});
|
||
expect(
|
||
computeTemplateGridLayout({ containerWidth: 1200, itemCount: 0 })
|
||
.rowCount,
|
||
).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('computeTemplateGridLayoutWithScrollbar', () => {
|
||
const innerWidth = (layout: { columnCount: number; columnWidth: number }) =>
|
||
layout.columnCount * layout.columnWidth;
|
||
|
||
it('reserves the classic scrollbar width once the grid scrolls vertically', () => {
|
||
// 经典滚动条(Windows/WebView2 ≈ 17px)不在包裹层宽度里,不预留就会多出一条横向滚动条。
|
||
const plain = computeTemplateGridLayout({
|
||
containerWidth: 1184,
|
||
itemCount: 13,
|
||
});
|
||
const layout = computeTemplateGridLayoutWithScrollbar({
|
||
containerWidth: 1184,
|
||
itemCount: 13,
|
||
viewportHeight: 500,
|
||
scrollbarWidth: 17,
|
||
});
|
||
|
||
expect(layout.columnCount).toBe(4);
|
||
// 内层宽度必须落在竖滚动条左侧的可用宽度里,否则又会出现横向滚动条。
|
||
expect(innerWidth(layout)).toBeLessThanOrEqual(1184 - 17);
|
||
expect(innerWidth(layout)).toBeLessThan(innerWidth(plain));
|
||
// 行高仍按预留后的列宽算,卡片内容不会被压。
|
||
expect(layout.rowHeight).toBe(computeTemplateRowHeight(layout.columnWidth));
|
||
});
|
||
|
||
it('keeps the plain layout when nothing overflows or the scrollbar is an overlay', () => {
|
||
const plain = computeTemplateGridLayout({
|
||
containerWidth: 1184,
|
||
itemCount: 4,
|
||
});
|
||
// 只有一行:不会竖向滚动,不需要预留,右侧不留白边。
|
||
expect(
|
||
computeTemplateGridLayoutWithScrollbar({
|
||
containerWidth: 1184,
|
||
itemCount: 4,
|
||
viewportHeight: 900,
|
||
scrollbarWidth: 17,
|
||
}),
|
||
).toEqual(plain);
|
||
// overlay 滚动条占宽 0:与不预留完全一致。
|
||
expect(
|
||
computeTemplateGridLayoutWithScrollbar({
|
||
containerWidth: 1184,
|
||
itemCount: 13,
|
||
viewportHeight: 500,
|
||
scrollbarWidth: 0,
|
||
}),
|
||
).toEqual(
|
||
computeTemplateGridLayout({ containerWidth: 1184, itemCount: 13 }),
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('buildTemplateRows', () => {
|
||
it('chunks entries per row and pads the tail with nulls', () => {
|
||
const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
|
||
expect(rows).toHaveLength(2);
|
||
expect(rows[0]?.map((item) => item?.id)).toEqual(['a', 'b']);
|
||
expect(rows[1]?.map((item) => item?.id ?? null)).toEqual(['c', null]);
|
||
});
|
||
|
||
it('returns no rows for an invalid column count', () => {
|
||
expect(buildTemplateRows([entry('a')], 0)).toEqual([]);
|
||
});
|
||
});
|