/** * @vitest-environment node * * Select 工具函数单元测试 * * 测试策略: * - 使用 vi.fn() 和 vi.mocked() 模拟 Playwright Page 对象 * - 测试函数内部逻辑而非真实浏览器交互 * - 验证选择器策略优先级(data-testid → aria-label → text) * - 验证错误处理和 E2ETestError 上下文完整性 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Page } from '@playwright/test'; // 从主导出点导入,验证 index.ts 导出配置正确 import { selectRadixOption, selectRadixOptionAsync, E2ETestError, DEFAULT_TIMEOUTS, throwError, type AsyncSelectOptions, type BaseOptions, type ErrorContext } from '@d8d/e2e-test-utils'; describe('selectRadixOption - 静态 Select 工具', () => { let mockPage: Page; beforeEach(() => { mockPage = { waitForSelector: vi.fn(), locator: vi.fn(), click: vi.fn(), waitForLoadState: vi.fn(), waitForTimeout: vi.fn(), } as unknown as Page; }); describe('成功选择场景 - data-testid 策略(第一优先级)', () => { it('应该使用 data-testid 策略成功选择选项', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; let callCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation(() => { callCount++; if (callCount === 1) return Promise.resolve(mockTrigger as any); if (callCount === 2) return Promise.resolve({} as any); if (callCount === 3) return Promise.resolve(mockOption as any); return Promise.reject(new Error('Not found')); }); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['视力残疾', '听力残疾']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOption(mockPage, '残疾类型', '视力残疾'); expect(mockTrigger.click).toHaveBeenCalled(); expect(mockOption.click).toHaveBeenCalled(); }); }); describe('成功选择场景 - aria-label 策略(第二优先级)', () => { it('应该在 data-testid 失败后使用 aria-label 策略', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; let callCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation(() => { callCount++; if (callCount === 1) return Promise.reject(new Error('data-testid failed')); if (callCount === 2) return Promise.resolve(mockTrigger as any); if (callCount === 3) return Promise.resolve({} as any); if (callCount === 4) return Promise.resolve(mockOption as any); return Promise.reject(new Error('Not found')); }); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['视力残疾']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOption(mockPage, '残疾类型', '视力残疾'); expect(mockTrigger.click).toHaveBeenCalled(); expect(mockOption.click).toHaveBeenCalled(); }); }); describe('成功选择场景 - text 策略(第三优先级/兜底)', () => { it('应该在前两个策略都失败后使用 text 策略', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; let callCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation(() => { callCount++; if (callCount <= 2) return Promise.reject(new Error('Strategy failed')); if (callCount === 3) return Promise.resolve(mockTrigger as any); if (callCount === 4) return Promise.resolve({} as any); if (callCount === 5) return Promise.resolve(mockOption as any); return Promise.reject(new Error('Not found')); }); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['视力残疾']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOption(mockPage, '残疾类型', '视力残疾'); expect(mockTrigger.click).toHaveBeenCalled(); expect(mockOption.click).toHaveBeenCalled(); }); }); describe('错误处理 - 触发器未找到', () => { it('应该在所有策略失败时抛出 E2ETestError', async () => { vi.mocked(mockPage.waitForSelector).mockRejectedValue(new Error('Not found')); await expect( selectRadixOption(mockPage, '残疾类型', '视力残疾') ).rejects.toThrow(E2ETestError); }); it('错误应该包含完整的上下文信息', async () => { vi.mocked(mockPage.waitForSelector).mockRejectedValue(new Error('Not found')); try { await selectRadixOption(mockPage, '残疾类型', '视力残疾'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.operation).toBe('selectRadixOption'); expect(e2eError.context.target).toBe('残疾类型'); expect(e2eError.context.expected).toBe('视力残疾'); expect(e2eError.message).toContain('selectRadixOption failed'); expect(e2eError.message).toContain('💡'); // 验证 suggestion 字段存在 expect(e2eError.context.suggestion).toBeDefined(); expect(typeof e2eError.context.suggestion).toBe('string'); } }); }); describe('错误处理 - 选项未找到', () => { it('应该在选项不存在时抛出 E2ETestError', async () => { const mockTrigger = { click: vi.fn() }; vi.mocked(mockPage.waitForSelector) .mockResolvedValueOnce(mockTrigger as any) .mockResolvedValueOnce({} as any) .mockRejectedValue(new Error('Option not found')); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['听力残疾', '肢体残疾']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await expect( selectRadixOption(mockPage, '残疾类型', '视力残疾') ).rejects.toThrow(E2ETestError); }); it('选项错误应该包含可用选项列表', async () => { const mockTrigger = { click: vi.fn() }; const availableOptions = ['听力残疾', '言语残疾', '肢体残疾']; vi.mocked(mockPage.waitForSelector) .mockResolvedValueOnce(mockTrigger as any) .mockResolvedValueOnce({} as any) .mockRejectedValue(new Error('Option not found')); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(availableOptions), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); try { await selectRadixOption(mockPage, '残疾类型', '视力残疾'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.available).toEqual(availableOptions); expect(e2eError.message).toContain('Available:'); } }); }); }); describe('主导出验证 (index.ts)', () => { it('应该正确导出 selectRadixOption 函数', () => { expect(selectRadixOption).toBeDefined(); expect(typeof selectRadixOption).toBe('function'); }); it('应该正确导出 selectRadixOptionAsync 函数', () => { expect(selectRadixOptionAsync).toBeDefined(); expect(typeof selectRadixOptionAsync).toBe('function'); }); it('应该正确导出 E2ETestError 错误类', () => { expect(E2ETestError).toBeDefined(); expect(typeof E2ETestError).toBe('function'); // 验证可以实例化 const error = new E2ETestError({ operation: 'test', target: 'test-target', suggestion: 'test-suggestion' }); expect(error).toBeInstanceOf(E2ETestError); expect(error.context.operation).toBe('test'); }); it('应该正确导出 DEFAULT_TIMEOUTS 常量', () => { expect(DEFAULT_TIMEOUTS).toBeDefined(); expect(typeof DEFAULT_TIMEOUTS).toBe('object'); expect(DEFAULT_TIMEOUTS.static).toBe(2000); expect(DEFAULT_TIMEOUTS.async).toBe(5000); expect(DEFAULT_TIMEOUTS.networkIdle).toBe(10000); }); it('应该正确导出 throwError 辅助函数', () => { expect(throwError).toBeDefined(); expect(typeof throwError).toBe('function'); // 验证函数抛出 E2ETestError expect(() => { throwError({ operation: 'test', target: 'test-target' }); }).toThrow(E2ETestError); }); it('应该正确导出 AsyncSelectOptions 类型', () => { const options: AsyncSelectOptions = { timeout: 8000, waitForNetworkIdle: false }; expect(options).toBeDefined(); }); it('应该正确导出 BaseOptions 类型', () => { const options: BaseOptions = { timeout: 5000 }; expect(options).toBeDefined(); }); it('应该正确导出 ErrorContext 类型', () => { const context: ErrorContext = { operation: 'test', target: 'test-target', suggestion: 'test-suggestion' }; expect(context).toBeDefined(); }); }); describe('selectRadixOptionAsync - 异步 Select 工具', () => { let mockPage: Page; beforeEach(() => { mockPage = { waitForSelector: vi.fn(), locator: vi.fn(), click: vi.fn(), waitForLoadState: vi.fn(), waitForTimeout: vi.fn(), } as unknown as Page; }); describe('默认配置成功选择', () => { it('应该使用默认配置成功选择选项', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; let callCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation(() => { callCount++; if (callCount === 1) return Promise.resolve(mockTrigger as any); if (callCount === 2) return Promise.resolve({} as any); if (callCount === 3) return Promise.resolve(mockOption as any); return Promise.reject(new Error('Not found')); }); // Mock waitForLoadState (networkidle) vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1', '选项2']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOptionAsync(mockPage, '测试下拉', '选项1'); expect(mockTrigger.click).toHaveBeenCalled(); expect(mockPage.waitForLoadState).toHaveBeenCalledWith('networkidle', { timeout: DEFAULT_TIMEOUTS.async, }); expect(mockOption.click).toHaveBeenCalled(); }); }); describe('自定义超时配置', () => { it('应该支持自定义 timeout 选项', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; const customTimeout = 8000; let callCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation((_selector: string, _options?: any) => { callCount++; if (callCount <= 2) return Promise.resolve(mockTrigger as any); if (callCount === 3) return Promise.resolve({} as any); if (callCount === 4) return Promise.resolve(mockOption as any); return Promise.reject(new Error('Not found')); }); vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOptionAsync(mockPage, '测试下拉', '选项1', { timeout: customTimeout }); expect(mockPage.waitForLoadState).toHaveBeenCalledWith('networkidle', { timeout: customTimeout, }); }); }); describe('禁用网络空闲等待', () => { it('应该支持 waitForNetworkIdle: false 选项', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; vi.mocked(mockPage.waitForSelector) .mockResolvedValueOnce(mockTrigger as any) .mockResolvedValueOnce({} as any) .mockResolvedValueOnce(mockOption as any); vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOptionAsync(mockPage, '测试下拉', '选项1', { waitForNetworkIdle: false }); // 不应该调用 waitForLoadState expect(mockPage.waitForLoadState).not.toHaveBeenCalled(); expect(mockOption.click).toHaveBeenCalled(); }); }); describe('异步选项加载超时处理', () => { it('应该在选项加载超时时抛出 E2ETestError', async () => { const mockTrigger = { click: vi.fn() }; vi.mocked(mockPage.waitForSelector) .mockResolvedValueOnce(mockTrigger as any) // 触发器 .mockResolvedValueOnce({} as any) // listbox .mockRejectedValue(new Error('Timeout waiting for option')); // 选项超时 vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await expect( selectRadixOptionAsync(mockPage, '测试下拉', '选项2') ).rejects.toThrow(E2ETestError); }); it('超时错误应该包含重试建议', async () => { const mockTrigger = { click: vi.fn() }; vi.mocked(mockPage.waitForSelector) .mockResolvedValueOnce(mockTrigger as any) .mockResolvedValueOnce({} as any) .mockRejectedValue(new Error('Timeout')); vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); try { await selectRadixOptionAsync(mockPage, '测试下拉', '选项2'); expect.fail('应该抛出错误'); } catch (error) { expect(error).toBeInstanceOf(E2ETestError); const e2eError = error as E2ETestError; expect(e2eError.context.operation).toBe('selectRadixOptionAsync'); expect(e2eError.message).toContain('💡'); } }); }); describe('触发器未找到错误', () => { it('应该在触发器未找到时抛出 E2ETestError', async () => { vi.mocked(mockPage.waitForSelector).mockRejectedValue(new Error('Trigger not found')); await expect( selectRadixOptionAsync(mockPage, '测试下拉', '选项1') ).rejects.toThrow(E2ETestError); }); }); describe('选项查找策略验证', () => { it('应该优先使用 data-value 策略查找选项', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; let lastSelector = ''; vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => { lastSelector = selector; if (selector.includes('trigger') || selector.includes('label')) { return Promise.resolve(mockTrigger as any); } if (selector.includes('listbox')) { return Promise.resolve({} as any); } if (selector.includes('data-value')) { return Promise.resolve(mockOption as any); } return Promise.reject(new Error('Not found')); }); vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); await selectRadixOptionAsync(mockPage, '测试下拉', '选项1'); // 应该使用 data-value 策略 expect(lastSelector).toContain('[data-value='); }); }); describe('重试机制验证', () => { it('应该在第一次失败后重试并成功选择选项', async () => { const mockTrigger = { click: vi.fn() }; const mockOption = { click: vi.fn() }; let attemptCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => { // 触发器和 listbox 直接返回 if (selector.includes('trigger') || selector.includes('label') || selector.includes('listbox')) { if (selector.includes('trigger') || selector.includes('label')) { return Promise.resolve(mockTrigger as any); } return Promise.resolve({} as any); } // 选项选择器:第一次失败,第二次成功(验证重试机制) attemptCount++; if (attemptCount === 1) { return Promise.reject(new Error('Option not loaded yet')); } // 第二次重试成功 return Promise.resolve(mockOption as any); }); vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); vi.mocked(mockPage.waitForTimeout).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); // 应该在重试后成功 await selectRadixOptionAsync(mockPage, '测试下拉', '选项1'); // 验证至少重试了一次 expect(attemptCount).toBeGreaterThanOrEqual(2); expect(mockOption.click).toHaveBeenCalled(); }); it('应该在多次重试后超时并抛出错误', async () => { const mockTrigger = { click: vi.fn() }; vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => { // 触发器成功 if (selector.includes('trigger') || selector.includes('label')) { return Promise.resolve(mockTrigger as any); } if (selector.includes('listbox')) { return Promise.resolve({} as any); } // 选项选择器持续失败 return Promise.reject(new Error('Option not loaded')); }); vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any); vi.mocked(mockPage.waitForTimeout).mockResolvedValue(undefined as any); const mockLocator = { allTextContents: vi.fn().mockResolvedValue([]), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); // 使用短超时加快测试 await expect( selectRadixOptionAsync(mockPage, '测试下拉', '选项1', { timeout: 500 }) ).rejects.toThrow(E2ETestError); }); }); });