// @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 & Pick, ): 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 { 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( {}} />); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); 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( {}} />, ); expect(screen.getByText('模板库暂时还没有可用的模板。')).toBeTruthy(); unmount(); const { unmount: unmountNoMatch } = render( {}} />, ); expect(screen.getByText('没有符合当前筛选的模板')).toBeTruthy(); unmountNoMatch(); render( {}} />, ); 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( {}} />, ); 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( 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( , ); 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( , ); expect(screen.getByText('需要在陶泥儿客户端内运行')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '打开模板库' })); expect(onOpenLibrary).toHaveBeenCalled(); }); it('renders a loading state before the first snapshot arrives', () => { render( {}} />, ); expect(screen.getByText('正在读取模板库…')).toBeTruthy(); }); });