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([]); }); });