/** * RankingPanel 组件测试 * 测试排行榜面板的核心功能,包括位置恢复和居中状态 * @jest-environment jsdom */ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import '@testing-library/jest-dom' // Mock fetch const mockFetch = jest.fn() global.fetch = mockFetch // Mock localStorage const localStorageMock = (() => { let store: Record = {} return { getItem: jest.fn((key: string) => store[key] || null), setItem: jest.fn((key: string, value: string) => { store[key] = value }), removeItem: jest.fn((key: string) => { delete store[key] }), clear: jest.fn(() => { store = {} }), get store() { return store }, } })() Object.defineProperty(window, 'localStorage', { value: localStorageMock, }) // Mock window dimensions Object.defineProperty(window, 'innerWidth', { value: 1920, writable: true }) Object.defineProperty(window, 'innerHeight', { value: 1080, writable: true }) import RankingPanel from '../RankingPanel' describe('RankingPanel 组件', () => { beforeEach(() => { jest.clearAllMocks() localStorageMock.clear() jest.useFakeTimers() // Mock API response mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ contributors: [ { username: 'user1', count: 10 }, { username: 'user2', count: 8 }, ], downloads: [ { username: 'user3', count: 100 }, ], likes: [ { username: 'user4', count: 50 }, ], }), }) }) afterEach(() => { jest.useRealTimers() }) describe('基础渲染测试', () => { it('应该正确渲染排行榜面板', async () => { await act(async () => { render() }) // 使用 getAllByText 因为有多个元素包含相同文本 const elements = screen.getAllByText('贡献榜') expect(elements.length).toBeGreaterThan(0) }) it('应该显示排行榜数据', async () => { await act(async () => { render() }) await waitFor(() => { expect(screen.getByText('user1')).toBeInTheDocument() expect(screen.getByText('10')).toBeInTheDocument() }) }) }) describe('位置恢复测试', () => { it('应该从 localStorage 恢复按钮位置', async () => { // 设置保存的位置(非居中) localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 100, y: 200, centered: false, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() // 验证 localStorage.getItem 被调用 expect(localStorageMock.getItem).toHaveBeenCalledWith('rankingPanelButtonPosition') // 按钮应该显示(因为面板已关闭) const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 验证位置样式 expect(button).toHaveStyle({ left: '100px', top: '200px' }) }) it('应该恢复居中状态为 true', async () => { // 设置保存的位置(居中) localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 32, y: 0, centered: true, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 居中状态下,top 应该是 50%,transform 应该包含 translateY(-50%) expect(button).toHaveStyle({ left: '32px', top: '50%' }) }) it('应该恢复居中状态为 false', async () => { // 设置保存的位置(非居中) localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 50, y: 300, centered: false, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 非居中状态下,top 应该是具体的像素值 expect(button).toHaveStyle({ left: '50px', top: '300px' }) }) it('没有 centered 字段时应该默认为 false', async () => { // 设置保存的位置(旧格式,没有 centered 字段) localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 80, y: 150, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 没有 centered 字段时,应该使用具体的 y 值 expect(button).toHaveStyle({ left: '80px', top: '150px' }) }) }) describe('关闭和打开测试', () => { it('关闭面板时应该保存居中状态', async () => { await act(async () => { render() }) // 使用 getAllByText 因为有多个元素包含相同文本 const elements = screen.getAllByText('贡献榜') expect(elements.length).toBeGreaterThan(0) // 点击关闭按钮 const closeButton = screen.getByLabelText('关闭') await act(async () => { fireEvent.click(closeButton) }) // 验证保存了位置和居中状态 expect(localStorageMock.setItem).toHaveBeenCalledWith( 'rankingPanelButtonPosition', expect.stringContaining('"centered"') ) }) it('打开面板时应该恢复位置', async () => { localStorageMock.setItem('rankingPanelClosed', 'true') localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 100, y: 200, centered: false, })) await act(async () => { render() }) // 点击按钮打开面板 const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 模拟快速点击(非拖动)- 需要在 act 中执行 await act(async () => { fireEvent.mouseDown(button!, { clientX: 100, clientY: 200 }) fireEvent.click(button!, { clientX: 100, clientY: 200 }) }) // 验证 localStorage.removeItem 被调用 expect(localStorageMock.removeItem).toHaveBeenCalledWith('rankingPanelClosed') }) }) describe('拖动测试', () => { it('拖动按钮后应该更新位置', async () => { localStorageMock.setItem('rankingPanelClosed', 'true') localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 32, y: 0, centered: true, })) await act(async () => { render() }) const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 模拟拖动开始 await act(async () => { fireEvent.mouseDown(button!, { clientX: 32, clientY: 540 }) }) // 模拟鼠标移动 await act(async () => { fireEvent.mouseMove(document, { clientX: 200, clientY: 300 }) }) // 模拟鼠标释放 await act(async () => { fireEvent.mouseUp(document) }) // 验证 setItem 被调用(位置被保存) const savedCalls = localStorageMock.setItem.mock.calls.filter( (call: any[]) => call[0] === 'rankingPanelButtonPosition' ) // 应该有保存位置的调用 expect(savedCalls.length).toBeGreaterThan(0) }) }) describe('边界情况测试', () => { it('应该处理无效的 JSON 数据', async () => { localStorageMock.setItem('rankingPanelButtonPosition', 'invalid json') localStorageMock.setItem('rankingPanelClosed', 'true') // 不应该抛出错误 expect(() => render()).not.toThrow() }) it('应该处理空的 localStorage', async () => { render() await waitFor(() => { expect(screen.getByText('贡献榜')).toBeInTheDocument() }) }) it('应该将位置限制在视口内', async () => { // 设置超出视口的位置 localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 9999, y: 9999, centered: false, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toBeInTheDocument() // 位置应该被限制在视口内 const style = button?.getAttribute('style') expect(style).toBeDefined() }) }) }) describe('RankingPanel 位置恢复逻辑', () => { beforeEach(() => { jest.clearAllMocks() localStorageMock.clear() mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ contributors: [], downloads: [], likes: [], }), }) }) it('centered: true 时按钮应该垂直居中', async () => { localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 32, y: 0, centered: true, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toHaveStyle({ left: '32px', top: '50%', transform: 'translateY(-50%)', }) }) it('centered: false 时按钮应该使用具体坐标', async () => { localStorageMock.setItem('rankingPanelButtonPosition', JSON.stringify({ x: 100, y: 200, centered: false, })) localStorageMock.setItem('rankingPanelClosed', 'true') render() const button = document.querySelector('button[title="显示排行榜(可拖动)"]') expect(button).toHaveStyle({ left: '100px', top: '200px', transform: 'none', }) }) })