/** * @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(), getByRole: vi.fn(), // Story 2.3 新增:用于策略 3 } 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('成功选择场景 - 策略 4(相邻 combobox 查找)', () => { it('应该在前三个策略都失败后使用相邻 combobox 查找', async () => { // 注意:策略 4(相邻 combobox 查找)的完整测试需要真实 DOM 环境 // 单元测试中难以完全模拟其链式调用(locator().first().locator("..").locator()) // 该策略的有效性通过集成测试验证 // 这里仅验证策略 4 的相关方法存在 // 验证 locator 方法在 Page 上存在(策略 4 使用它) expect(mockPage.locator).toBeDefined(); expect(typeof mockPage.locator).toBe('function'); }); }); describe('新选择器策略测试 (Story 2.3 新增)', () => { describe('策略 3: getByRole("combobox", { name: label })', () => { it('应该使用 getByRole 查找触发器(策略 3)', async () => { // 模拟 getByRole 返回一个带 waitFor 方法的 Locator const mockLocatorWithWait = { waitFor: vi.fn().mockResolvedValue(undefined), click: vi.fn().mockResolvedValue(undefined), }; // 创建 mock getByRole 方法 const mockGetByRole = vi.fn().mockReturnValue(mockLocatorWithWait); (mockPage as any).getByRole = mockGetByRole; // 前 2 个策略失败(data-testid 和 aria-label) // 第 3 个策略使用 getByRole,但我们需要模拟 waitFor 返回 locator let callCount = 0; vi.mocked(mockPage.waitForSelector).mockImplementation(() => { callCount++; if (callCount <= 2) return Promise.reject(new Error('Strategy 1&2 failed')); return Promise.reject(new Error('Should not reach here')); }); // 模拟 locator 用于查找选项 const mockLocator = { allTextContents: vi.fn().mockResolvedValue(['选项1']), }; vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); // 由于策略 3 返回的是 Locator,我们需要修改 mock 来正确处理 // 在实际实现中,策略 3 会先调用 locator.waitFor() 然后返回 locator // 让我们创建一个更完整的 mock // 重新设置:策略 3 成功场景 (mockPage as any).getByRole = vi.fn().mockReturnValue({ waitFor: vi.fn().mockResolvedValue(undefined), click: vi.fn().mockResolvedValue(undefined), }); // 模拟 listbox 和 option vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => { if (selector.includes('listbox')) return Promise.resolve({} as any); return Promise.reject(new Error('Not found')); }); // 由于单元测试的复杂性,我们通过集成测试验证策略 3 // 这里只验证 getByRole 方法存在 expect((mockPage as any).getByRole).toBeDefined(); }); it('策略 3 应该使用 exact: true 参数进行精确匹配', async () => { // 验证 getByRole 被调用时使用了 exact: true 选项 const mockGetByRole = vi.fn(); (mockPage as any).getByRole = mockGetByRole; // 调用应该失败(因为没有其他成功的策略),但我们可以验证 getByRole 的调用 // 由于实现细节,我们通过集成测试来验证 exact 参数 expect(mockGetByRole).toBeDefined(); }); }); describe('策略 4: 相邻 combobox 查找', () => { it('应该在所有其他策略失败后尝试相邻 combobox 查找(策略 4)', async () => { // 策略 4 的逻辑:找到包含标签文本的元素,然后找相邻的 combobox // 这是一个 fallback 策略,用于处理特殊的 DOM 结构 // 验证 page.locator 方法存在(策略 4 使用它) const mockLocator = vi.fn(); vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any); expect(mockPage.locator).toBeDefined(); }); it('策略 4 应该查找标签文本的父元素然后找 combobox', async () => { // 验证 locator 方法可以链式调用 const mockParentLocator = { locator: vi.fn() }; const mockComboboxLocator = { count: vi.fn().mockResolvedValue(1), waitFor: vi.fn().mockResolvedValue(undefined), }; // 模拟 locator("text=label") -> locator("..") -> locator('[role="combobox"]') vi.mocked(mockPage.locator).mockImplementation((selector: string) => { if (selector.includes('text=')) { return { first: vi.fn().mockReturnValue({ locator: vi.fn().mockReturnValue({ locator: vi.fn().mockReturnValue(mockComboboxLocator), }), }), } as any; } return mockParentLocator as any; }); // 验证链式调用结构正确 const textLocator = mockPage.locator('text="测试"'); expect(textLocator).toBeDefined(); }); }); describe('新选择器策略优先级', () => { it('应该按正确顺序尝试选择器策略:1 → 2 → 3 → 4', async () => { // 验证所有策略都存在并可调用 expect(mockPage.waitForSelector).toBeDefined(); // 策略 1, 2 expect((mockPage as any).getByRole).toBeDefined(); // 策略 3 expect(mockPage.locator).toBeDefined(); // 策略 4 // 实际的优先级顺序在集成测试中验证 }); }); }); 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(), getByRole: vi.fn(), // Story 2.3 新增:用于策略 3 } 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); }); }); });