import { describe, expect, it, vi } from 'vitest'; import { boardPageSkeleton, detailSkeletonBody } from '../src/board-skeleton'; import { wireBoardSegments } from '../src/board-controls'; vi.mock('@peach/legacy/core', async importOriginal => ({ ...await importOriginal(), icon: (name: string) => ``, })); describe('Board 页面骨架', () => { it.each([ ['subscriptions', '来源与订阅'], ['downloads', '下载渠道'], ['network', '网络与访问'], ['maintenance', '应用与维护'], ])('配置深链 %s 使用对应分区', (key, label) => { const root = document.createElement('div'); root.innerHTML = boardPageSkeleton(`/settings/${key}`); expect(root.querySelector('.ui-board-local-nav [aria-selected="true"]')?.textContent).toBe(label); expect(root.querySelector('[aria-label="媒体库与目录"]')).toBeNull(); }); it.each(['/stats', '/taste', '/subscriptions', '/settings/library', '/settings/diagnostics', '/tasks', '/duplicates', '/quality-goals', '/playlists', '/settings/maintenance/repairs'])('为 %s 提供单一等待语义', path => { const root = document.createElement('div'); root.innerHTML = boardPageSkeleton(path); expect(root.querySelectorAll('[role="status"]')).toHaveLength(1); expect(root.querySelector('[aria-hidden="true"]')).not.toBeNull(); expect(root.querySelector('[aria-hidden="true"][inert]')).not.toBeNull(); for (const control of root.querySelectorAll('button, input, a, summary')) { expect(control.closest('[inert]')).not.toBeNull(); } }); it('JAV 新作分区在列表模式下画新作自己的表格骨架,下排有卡片/列表开关、没有文件夹开关', () => { const before = location.pathname + location.search; history.replaceState(null, '', '/jav?tab=releases'); localStorage.setItem('peach.catalog-view.v1', 'list'); const root = document.createElement('div'); root.innerHTML = boardPageSkeleton('/jav'); localStorage.removeItem('peach.catalog-view.v1'); history.replaceState(null, '', before); expect([...root.querySelectorAll('[data-catalog-table] th')].map(node => node.textContent).filter(Boolean)) .toEqual(['作品', '艺人', '厂牌', '标签', '发行时间', '时长', '来源评分', '操作']); expect(root.querySelector('[data-skeleton="jav-discover"]')).toBeNull(); expect(root.querySelector('[aria-label="馆藏显示方式"] [aria-checked="true"]')?.getAttribute('aria-label')).toBe('列表'); expect(root.querySelector('[data-folder-mode-toggle]')).toBeNull(); }); it('JAV 新作分区骨架按下新作,页头是名册页那种筛选框,下面是两排头像占位与新作自己的排序键', () => { const before = location.pathname + location.search; history.replaceState(null, '', '/jav?tab=releases&sort=duration&dir=asc'); const root = document.createElement('div'); root.innerHTML = boardPageSkeleton('/jav'); history.replaceState(null, '', before); expect([...root.querySelectorAll('span.whitespace-nowrap.rounded-md')].find(node => node.classList.contains('text-text-primary'))?.textContent).toBe('新作'); expect(root.querySelector('[data-index-search] input')?.getAttribute('aria-label')).toBe('筛选新作'); expect(root.querySelector('form')).toBeNull(); expect(root.querySelectorAll('[data-catalog-tier] [data-catalog-placeholder]').length).toBeGreaterThan(0); expect(root.querySelector('[data-entity-tag="external"]')).toBeNull(); expect([...root.querySelectorAll('[data-entity-sort]')].map(node => node.textContent)).toEqual(['发行时间', '来源评分', '时长', '番号', '口味']); expect(root.querySelector('[data-entity-sort][aria-pressed="true"]')?.getAttribute('data-entity-sort')).toBe('duration'); expect(root.querySelector('[data-entity-batch]')?.getAttribute('aria-label')).toBe('换一批'); }); it('统计预留四张指标卡,未知页面交给自身骨架', () => { const root = document.createElement('div'); root.innerHTML = boardPageSkeleton('/stats'); expect(root.querySelectorAll('[data-stats-metrics] > div')).toHaveLength(4); expect(boardPageSkeleton('/assets/5')).toBe(''); }); it.each([ ['/stats', '[data-stats-chart]', 2], ['/tasks', '.activitysection', 3], ['/duplicates', '[data-duplicate-group] .duplicate-row', 4], ['/quality-goals', '.peach-react .card-grid-cover > li', 6], ['/subscriptions', '.peach-react .ui-follow-skeleton-surface .follow-skeleton-author', 3], ['/settings/library', '.peach-react .ui-configpage section[aria-label="媒体库与目录"]', 1], ['/playlists', '.ui-playlistcards > .playlistcard', 6], ] as const)('%s 复用最终内容容器', (path, selector, count) => { const root = document.createElement('div'); root.innerHTML = boardPageSkeleton(path); expect(root.querySelectorAll(selector)).toHaveLength(count); expect(root.querySelector('.skeleton-cards')).toBeNull(); }); it('详情保留媒体区和资料侧栏,不创建播放器', () => { const root = document.createElement('div'); root.innerHTML = detailSkeletonBody(); expect(root.querySelector('[data-stage-grid] > [data-stage-media]')).not.toBeNull(); expect(root.querySelector('[data-stage-grid] > [data-stage-side] > [data-stage-side-content]')).not.toBeNull(); expect(root.querySelectorAll('video, audio, iframe')).toHaveLength(0); }); const follow = (options: Parameters[1] = {}) => { const root = document.createElement('div'); root.innerHTML = boardPageSkeleton('/subscriptions', options); return root; }; it('关注骨架的分类使用 Pills,选中关注列表', () => { const root = follow(); wireBoardSegments(root); expect(root.querySelector('.board-segment-thumb')).toBeNull(); expect(root.querySelector('[data-section-items] [aria-selected=true]')?.textContent).toBe('关注列表'); const track = root.querySelector('[data-section-items]')!; expect([...track.children].map((span) => span.textContent)).toEqual(['关注列表', '添加关注', '标签管理']); }); it('关注骨架选中地址栏 `?tab=` 那一枚页签,列表以外的页签不画关注列表的工具行', () => { const tags = follow({ followTab: 'tags' }); expect(tags.querySelector('[data-section-items] [aria-selected=true]')?.textContent).toBe('标签管理'); expect(tags.querySelector('.ui-follow-skeleton-toolbar')).toBeNull(); expect(tags.querySelector('.ui-follow-skeleton-surface')).not.toBeNull(); expect(follow({ followTab: 'feeds' }).querySelector('[data-section-items] [aria-selected=true]')?.textContent).toBe('关注列表'); expect(follow({ followTab: 'wants' }).querySelector('[data-section-items] [aria-selected=true]')?.textContent).toBe('关注列表'); expect(follow({ followTab: 'nope' }).querySelector('[data-section-items] [aria-selected=true]')?.textContent).toBe('关注列表'); }); it('关注骨架的工具行按 Board UI 最终变体画:检查全部是主按钮,视图切换是按版式选中的按钮组', () => { const toolbar = follow().querySelector('.ui-follow-skeleton-toolbar')!; const check = toolbar.querySelector('[data-follow-toolbar-actions] > button')!; expect(check.textContent).toBe('检查全部'); expect(check.classList.contains('bg-button-primary')).toBe(true); const group = [...toolbar.querySelectorAll('[data-button-group][role="group"] > button')]; expect(group.map((button) => button.querySelector('use')?.getAttribute('href'))).toEqual(['#i-layout-grid', '#i-table']); expect(group.map((button) => button.getAttribute('aria-pressed'))).toEqual(['true', 'false']); expect(group.every((button) => button.classList.contains('bg-button-ghost-background'))).toBe(true); expect(toolbar.querySelector('button[aria-haspopup="listbox"]')?.textContent).toBe('排序检查时间'); expect([...toolbar.querySelectorAll('button')].at(-1)?.textContent).toBe('全部收起'); const tableGroup = follow({ followLayout: 'table' }).querySelectorAll('[data-button-group] > button'); expect([...tableGroup].map((button) => button.getAttribute('aria-pressed'))).toEqual(['false', 'true']); }); it('排序框与方向键跟着地址栏上的排序', () => { const toolbar = (options: Parameters[1]) => follow(options).querySelector('.ui-follow-skeleton-toolbar')!; const arrow = (root: Element) => root.querySelector('button[aria-haspopup]')!.parentElement! .nextElementSibling!.querySelector('use')?.getAttribute('href'); expect(arrow(toolbar({}))).toBe('#i-arrow-down'); const byName = toolbar({ followSort: 'name' }); expect(byName.querySelector('button[aria-haspopup]')?.textContent).toBe('排序创作者名称'); expect(arrow(byName)).toBe('#i-arrow-up'); expect(arrow(toolbar({ followSort: 'name', followDir: 'desc' }))).toBe('#i-arrow-down'); }); it('创作者卡与来源行的动作键是小号次级键,和最终行内操作同一形态', () => { const root = follow(); expect(root.querySelectorAll('.follow-skeleton-source')).toHaveLength(10); for (const row of root.querySelectorAll('.follow-skeleton-source')) { const actions = [...row.lastElementChild!.lastElementChild!.children]; expect(actions.map((button) => button.querySelector('use')?.getAttribute('href'))).toEqual(['#i-refresh-cw', '#i-trash']); for (const button of actions) expect(button.className).toContain('size-8 rounded-lg p-0'); } const head = root.querySelector('.follow-skeleton-author [data-follow-author-header]')!; expect([...head.querySelectorAll('button')].map((button) => button.textContent)).toEqual(['', '全选', '收起']); expect(root.querySelector('.ui-follow-skeleton-surface > .group')?.textContent).toBe('全选本页'); expect(root.querySelector('[data-selection-dock]')).toBeNull(); }); it('口味骨架画在 React 岛里,按浏览器记录那一页排', () => { const root = document.createElement('div'); root.innerHTML = boardPageSkeleton('/taste'); expect(root.querySelector('.peach-react')).not.toBeNull(); expect(root.querySelector('[data-taste-segments] [data-selected]')?.textContent).toBe('浏览器记录'); expect([...root.querySelectorAll('[data-taste-metrics] > div > span')].map((span) => span.textContent)) .toEqual(['浏览记录', '口味维度', '浏览线索', '私有导出']); expect(root.querySelector('[data-taste-portrait]')?.textContent).toContain('浏览器画像'); expect([...root.querySelectorAll('h3')].map((h3) => h3.textContent)) .toEqual(['浏览活跃时间', '每日活跃', '创作者线索来源']); }); it('关注表格视图是 Board UI 表格外框,当前排序那一列带方向', () => { const root = follow({ followLayout: 'table' }); const table = root.querySelector('.follow-skeleton-table[data-board-data-table] table.bui-table')!; expect([...table.querySelectorAll('thead th')].map((th) => th.textContent)).toEqual(['', '创作者', '来源', '站点', '状态', '上次检查', '']); expect(table.querySelectorAll('tbody tr')).toHaveLength(20); expect([...table.querySelectorAll('[data-sort-indicator][data-direction]')].map((mark) => [mark.parentElement!.textContent, mark.getAttribute('data-direction')])) .toEqual([['上次检查', 'descending']]); expect(root.querySelector('.follow-skeleton-author')).toBeNull(); expect(root.querySelector('.ui-follow-skeleton-toolbar')?.textContent).not.toContain('全部收起'); }); it('表格骨架的行数跟着每页条数走', () => { localStorage.setItem('peach.settings.v1', JSON.stringify({ followPageSize: 40 })); try { const root = follow({ followLayout: 'table' }); expect(root.querySelectorAll('.follow-skeleton-table tbody tr')).toHaveLength(40); expect(root.querySelector('.ui-follow-skeleton-surface > :last-child button[aria-haspopup]')?.textContent).toBe('每页 40 条'); } finally { localStorage.removeItem('peach.settings.v1') } }); });