2
0

radix-select.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. /**
  2. * @vitest-environment node
  3. *
  4. * Select 工具函数单元测试
  5. *
  6. * 测试策略:
  7. * - 使用 vi.fn() 和 vi.mocked() 模拟 Playwright Page 对象
  8. * - 测试函数内部逻辑而非真实浏览器交互
  9. * - 验证选择器策略优先级(data-testid → aria-label → text)
  10. * - 验证错误处理和 E2ETestError 上下文完整性
  11. */
  12. import { describe, it, expect, vi, beforeEach } from 'vitest';
  13. import type { Page } from '@playwright/test';
  14. // 从主导出点导入,验证 index.ts 导出配置正确
  15. import {
  16. selectRadixOption,
  17. selectRadixOptionAsync,
  18. E2ETestError,
  19. DEFAULT_TIMEOUTS,
  20. throwError,
  21. type AsyncSelectOptions,
  22. type BaseOptions,
  23. type ErrorContext
  24. } from '@d8d/e2e-test-utils';
  25. describe('selectRadixOption - 静态 Select 工具', () => {
  26. let mockPage: Page;
  27. beforeEach(() => {
  28. mockPage = {
  29. waitForSelector: vi.fn(),
  30. locator: vi.fn(),
  31. click: vi.fn(),
  32. waitForLoadState: vi.fn(),
  33. waitForTimeout: vi.fn(),
  34. } as unknown as Page;
  35. });
  36. describe('成功选择场景 - data-testid 策略(第一优先级)', () => {
  37. it('应该使用 data-testid 策略成功选择选项', async () => {
  38. const mockTrigger = { click: vi.fn() };
  39. const mockOption = { click: vi.fn() };
  40. let callCount = 0;
  41. vi.mocked(mockPage.waitForSelector).mockImplementation(() => {
  42. callCount++;
  43. if (callCount === 1) return Promise.resolve(mockTrigger as any);
  44. if (callCount === 2) return Promise.resolve({} as any);
  45. if (callCount === 3) return Promise.resolve(mockOption as any);
  46. return Promise.reject(new Error('Not found'));
  47. });
  48. const mockLocator = {
  49. allTextContents: vi.fn().mockResolvedValue(['视力残疾', '听力残疾']),
  50. };
  51. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  52. await selectRadixOption(mockPage, '残疾类型', '视力残疾');
  53. expect(mockTrigger.click).toHaveBeenCalled();
  54. expect(mockOption.click).toHaveBeenCalled();
  55. });
  56. });
  57. describe('成功选择场景 - aria-label 策略(第二优先级)', () => {
  58. it('应该在 data-testid 失败后使用 aria-label 策略', async () => {
  59. const mockTrigger = { click: vi.fn() };
  60. const mockOption = { click: vi.fn() };
  61. let callCount = 0;
  62. vi.mocked(mockPage.waitForSelector).mockImplementation(() => {
  63. callCount++;
  64. if (callCount === 1) return Promise.reject(new Error('data-testid failed'));
  65. if (callCount === 2) return Promise.resolve(mockTrigger as any);
  66. if (callCount === 3) return Promise.resolve({} as any);
  67. if (callCount === 4) return Promise.resolve(mockOption as any);
  68. return Promise.reject(new Error('Not found'));
  69. });
  70. const mockLocator = {
  71. allTextContents: vi.fn().mockResolvedValue(['视力残疾']),
  72. };
  73. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  74. await selectRadixOption(mockPage, '残疾类型', '视力残疾');
  75. expect(mockTrigger.click).toHaveBeenCalled();
  76. expect(mockOption.click).toHaveBeenCalled();
  77. });
  78. });
  79. describe('成功选择场景 - text 策略(第三优先级/兜底)', () => {
  80. it('应该在前两个策略都失败后使用 text 策略', async () => {
  81. const mockTrigger = { click: vi.fn() };
  82. const mockOption = { click: vi.fn() };
  83. let callCount = 0;
  84. vi.mocked(mockPage.waitForSelector).mockImplementation(() => {
  85. callCount++;
  86. if (callCount <= 2) return Promise.reject(new Error('Strategy failed'));
  87. if (callCount === 3) return Promise.resolve(mockTrigger as any);
  88. if (callCount === 4) return Promise.resolve({} as any);
  89. if (callCount === 5) return Promise.resolve(mockOption as any);
  90. return Promise.reject(new Error('Not found'));
  91. });
  92. const mockLocator = {
  93. allTextContents: vi.fn().mockResolvedValue(['视力残疾']),
  94. };
  95. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  96. await selectRadixOption(mockPage, '残疾类型', '视力残疾');
  97. expect(mockTrigger.click).toHaveBeenCalled();
  98. expect(mockOption.click).toHaveBeenCalled();
  99. });
  100. });
  101. describe('错误处理 - 触发器未找到', () => {
  102. it('应该在所有策略失败时抛出 E2ETestError', async () => {
  103. vi.mocked(mockPage.waitForSelector).mockRejectedValue(new Error('Not found'));
  104. await expect(
  105. selectRadixOption(mockPage, '残疾类型', '视力残疾')
  106. ).rejects.toThrow(E2ETestError);
  107. });
  108. it('错误应该包含完整的上下文信息', async () => {
  109. vi.mocked(mockPage.waitForSelector).mockRejectedValue(new Error('Not found'));
  110. try {
  111. await selectRadixOption(mockPage, '残疾类型', '视力残疾');
  112. expect.fail('应该抛出错误');
  113. } catch (error) {
  114. expect(error).toBeInstanceOf(E2ETestError);
  115. const e2eError = error as E2ETestError;
  116. expect(e2eError.context.operation).toBe('selectRadixOption');
  117. expect(e2eError.context.target).toBe('残疾类型');
  118. expect(e2eError.context.expected).toBe('视力残疾');
  119. expect(e2eError.message).toContain('selectRadixOption failed');
  120. expect(e2eError.message).toContain('💡');
  121. // 验证 suggestion 字段存在
  122. expect(e2eError.context.suggestion).toBeDefined();
  123. expect(typeof e2eError.context.suggestion).toBe('string');
  124. }
  125. });
  126. });
  127. describe('错误处理 - 选项未找到', () => {
  128. it('应该在选项不存在时抛出 E2ETestError', async () => {
  129. const mockTrigger = { click: vi.fn() };
  130. vi.mocked(mockPage.waitForSelector)
  131. .mockResolvedValueOnce(mockTrigger as any)
  132. .mockResolvedValueOnce({} as any)
  133. .mockRejectedValue(new Error('Option not found'));
  134. const mockLocator = {
  135. allTextContents: vi.fn().mockResolvedValue(['听力残疾', '肢体残疾']),
  136. };
  137. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  138. await expect(
  139. selectRadixOption(mockPage, '残疾类型', '视力残疾')
  140. ).rejects.toThrow(E2ETestError);
  141. });
  142. it('选项错误应该包含可用选项列表', async () => {
  143. const mockTrigger = { click: vi.fn() };
  144. const availableOptions = ['听力残疾', '言语残疾', '肢体残疾'];
  145. vi.mocked(mockPage.waitForSelector)
  146. .mockResolvedValueOnce(mockTrigger as any)
  147. .mockResolvedValueOnce({} as any)
  148. .mockRejectedValue(new Error('Option not found'));
  149. const mockLocator = {
  150. allTextContents: vi.fn().mockResolvedValue(availableOptions),
  151. };
  152. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  153. try {
  154. await selectRadixOption(mockPage, '残疾类型', '视力残疾');
  155. expect.fail('应该抛出错误');
  156. } catch (error) {
  157. expect(error).toBeInstanceOf(E2ETestError);
  158. const e2eError = error as E2ETestError;
  159. expect(e2eError.context.available).toEqual(availableOptions);
  160. expect(e2eError.message).toContain('Available:');
  161. }
  162. });
  163. });
  164. });
  165. describe('主导出验证 (index.ts)', () => {
  166. it('应该正确导出 selectRadixOption 函数', () => {
  167. expect(selectRadixOption).toBeDefined();
  168. expect(typeof selectRadixOption).toBe('function');
  169. });
  170. it('应该正确导出 selectRadixOptionAsync 函数', () => {
  171. expect(selectRadixOptionAsync).toBeDefined();
  172. expect(typeof selectRadixOptionAsync).toBe('function');
  173. });
  174. it('应该正确导出 E2ETestError 错误类', () => {
  175. expect(E2ETestError).toBeDefined();
  176. expect(typeof E2ETestError).toBe('function');
  177. // 验证可以实例化
  178. const error = new E2ETestError({
  179. operation: 'test',
  180. target: 'test-target',
  181. suggestion: 'test-suggestion'
  182. });
  183. expect(error).toBeInstanceOf(E2ETestError);
  184. expect(error.context.operation).toBe('test');
  185. });
  186. it('应该正确导出 DEFAULT_TIMEOUTS 常量', () => {
  187. expect(DEFAULT_TIMEOUTS).toBeDefined();
  188. expect(typeof DEFAULT_TIMEOUTS).toBe('object');
  189. expect(DEFAULT_TIMEOUTS.static).toBe(2000);
  190. expect(DEFAULT_TIMEOUTS.async).toBe(5000);
  191. expect(DEFAULT_TIMEOUTS.networkIdle).toBe(10000);
  192. });
  193. it('应该正确导出 throwError 辅助函数', () => {
  194. expect(throwError).toBeDefined();
  195. expect(typeof throwError).toBe('function');
  196. // 验证函数抛出 E2ETestError
  197. expect(() => {
  198. throwError({
  199. operation: 'test',
  200. target: 'test-target'
  201. });
  202. }).toThrow(E2ETestError);
  203. });
  204. it('应该正确导出 AsyncSelectOptions 类型', () => {
  205. const options: AsyncSelectOptions = {
  206. timeout: 8000,
  207. waitForNetworkIdle: false
  208. };
  209. expect(options).toBeDefined();
  210. });
  211. it('应该正确导出 BaseOptions 类型', () => {
  212. const options: BaseOptions = {
  213. timeout: 5000
  214. };
  215. expect(options).toBeDefined();
  216. });
  217. it('应该正确导出 ErrorContext 类型', () => {
  218. const context: ErrorContext = {
  219. operation: 'test',
  220. target: 'test-target',
  221. suggestion: 'test-suggestion'
  222. };
  223. expect(context).toBeDefined();
  224. });
  225. });
  226. describe('selectRadixOptionAsync - 异步 Select 工具', () => {
  227. let mockPage: Page;
  228. beforeEach(() => {
  229. mockPage = {
  230. waitForSelector: vi.fn(),
  231. locator: vi.fn(),
  232. click: vi.fn(),
  233. waitForLoadState: vi.fn(),
  234. waitForTimeout: vi.fn(),
  235. } as unknown as Page;
  236. });
  237. describe('默认配置成功选择', () => {
  238. it('应该使用默认配置成功选择选项', async () => {
  239. const mockTrigger = { click: vi.fn() };
  240. const mockOption = { click: vi.fn() };
  241. let callCount = 0;
  242. vi.mocked(mockPage.waitForSelector).mockImplementation(() => {
  243. callCount++;
  244. if (callCount === 1) return Promise.resolve(mockTrigger as any);
  245. if (callCount === 2) return Promise.resolve({} as any);
  246. if (callCount === 3) return Promise.resolve(mockOption as any);
  247. return Promise.reject(new Error('Not found'));
  248. });
  249. // Mock waitForLoadState (networkidle)
  250. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  251. const mockLocator = {
  252. allTextContents: vi.fn().mockResolvedValue(['选项1', '选项2']),
  253. };
  254. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  255. await selectRadixOptionAsync(mockPage, '测试下拉', '选项1');
  256. expect(mockTrigger.click).toHaveBeenCalled();
  257. expect(mockPage.waitForLoadState).toHaveBeenCalledWith('networkidle', {
  258. timeout: DEFAULT_TIMEOUTS.async,
  259. });
  260. expect(mockOption.click).toHaveBeenCalled();
  261. });
  262. });
  263. describe('自定义超时配置', () => {
  264. it('应该支持自定义 timeout 选项', async () => {
  265. const mockTrigger = { click: vi.fn() };
  266. const mockOption = { click: vi.fn() };
  267. const customTimeout = 8000;
  268. let callCount = 0;
  269. vi.mocked(mockPage.waitForSelector).mockImplementation((_selector: string, _options?: any) => {
  270. callCount++;
  271. if (callCount <= 2) return Promise.resolve(mockTrigger as any);
  272. if (callCount === 3) return Promise.resolve({} as any);
  273. if (callCount === 4) return Promise.resolve(mockOption as any);
  274. return Promise.reject(new Error('Not found'));
  275. });
  276. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  277. const mockLocator = {
  278. allTextContents: vi.fn().mockResolvedValue(['选项1']),
  279. };
  280. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  281. await selectRadixOptionAsync(mockPage, '测试下拉', '选项1', { timeout: customTimeout });
  282. expect(mockPage.waitForLoadState).toHaveBeenCalledWith('networkidle', {
  283. timeout: customTimeout,
  284. });
  285. });
  286. });
  287. describe('禁用网络空闲等待', () => {
  288. it('应该支持 waitForNetworkIdle: false 选项', async () => {
  289. const mockTrigger = { click: vi.fn() };
  290. const mockOption = { click: vi.fn() };
  291. vi.mocked(mockPage.waitForSelector)
  292. .mockResolvedValueOnce(mockTrigger as any)
  293. .mockResolvedValueOnce({} as any)
  294. .mockResolvedValueOnce(mockOption as any);
  295. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  296. const mockLocator = {
  297. allTextContents: vi.fn().mockResolvedValue(['选项1']),
  298. };
  299. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  300. await selectRadixOptionAsync(mockPage, '测试下拉', '选项1', { waitForNetworkIdle: false });
  301. // 不应该调用 waitForLoadState
  302. expect(mockPage.waitForLoadState).not.toHaveBeenCalled();
  303. expect(mockOption.click).toHaveBeenCalled();
  304. });
  305. });
  306. describe('异步选项加载超时处理', () => {
  307. it('应该在选项加载超时时抛出 E2ETestError', async () => {
  308. const mockTrigger = { click: vi.fn() };
  309. vi.mocked(mockPage.waitForSelector)
  310. .mockResolvedValueOnce(mockTrigger as any) // 触发器
  311. .mockResolvedValueOnce({} as any) // listbox
  312. .mockRejectedValue(new Error('Timeout waiting for option')); // 选项超时
  313. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  314. const mockLocator = {
  315. allTextContents: vi.fn().mockResolvedValue(['选项1']),
  316. };
  317. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  318. await expect(
  319. selectRadixOptionAsync(mockPage, '测试下拉', '选项2')
  320. ).rejects.toThrow(E2ETestError);
  321. });
  322. it('超时错误应该包含重试建议', async () => {
  323. const mockTrigger = { click: vi.fn() };
  324. vi.mocked(mockPage.waitForSelector)
  325. .mockResolvedValueOnce(mockTrigger as any)
  326. .mockResolvedValueOnce({} as any)
  327. .mockRejectedValue(new Error('Timeout'));
  328. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  329. const mockLocator = {
  330. allTextContents: vi.fn().mockResolvedValue(['选项1']),
  331. };
  332. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  333. try {
  334. await selectRadixOptionAsync(mockPage, '测试下拉', '选项2');
  335. expect.fail('应该抛出错误');
  336. } catch (error) {
  337. expect(error).toBeInstanceOf(E2ETestError);
  338. const e2eError = error as E2ETestError;
  339. expect(e2eError.context.operation).toBe('selectRadixOptionAsync');
  340. expect(e2eError.message).toContain('💡');
  341. }
  342. });
  343. });
  344. describe('触发器未找到错误', () => {
  345. it('应该在触发器未找到时抛出 E2ETestError', async () => {
  346. vi.mocked(mockPage.waitForSelector).mockRejectedValue(new Error('Trigger not found'));
  347. await expect(
  348. selectRadixOptionAsync(mockPage, '测试下拉', '选项1')
  349. ).rejects.toThrow(E2ETestError);
  350. });
  351. });
  352. describe('选项查找策略验证', () => {
  353. it('应该优先使用 data-value 策略查找选项', async () => {
  354. const mockTrigger = { click: vi.fn() };
  355. const mockOption = { click: vi.fn() };
  356. let lastSelector = '';
  357. vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => {
  358. lastSelector = selector;
  359. if (selector.includes('trigger') || selector.includes('label')) {
  360. return Promise.resolve(mockTrigger as any);
  361. }
  362. if (selector.includes('listbox')) {
  363. return Promise.resolve({} as any);
  364. }
  365. if (selector.includes('data-value')) {
  366. return Promise.resolve(mockOption as any);
  367. }
  368. return Promise.reject(new Error('Not found'));
  369. });
  370. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  371. const mockLocator = {
  372. allTextContents: vi.fn().mockResolvedValue(['选项1']),
  373. };
  374. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  375. await selectRadixOptionAsync(mockPage, '测试下拉', '选项1');
  376. // 应该使用 data-value 策略
  377. expect(lastSelector).toContain('[data-value=');
  378. });
  379. });
  380. describe('重试机制验证', () => {
  381. it('应该在第一次失败后重试并成功选择选项', async () => {
  382. const mockTrigger = { click: vi.fn() };
  383. const mockOption = { click: vi.fn() };
  384. let attemptCount = 0;
  385. vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => {
  386. // 触发器和 listbox 直接返回
  387. if (selector.includes('trigger') || selector.includes('label') || selector.includes('listbox')) {
  388. if (selector.includes('trigger') || selector.includes('label')) {
  389. return Promise.resolve(mockTrigger as any);
  390. }
  391. return Promise.resolve({} as any);
  392. }
  393. // 选项选择器:第一次失败,第二次成功(验证重试机制)
  394. attemptCount++;
  395. if (attemptCount === 1) {
  396. return Promise.reject(new Error('Option not loaded yet'));
  397. }
  398. // 第二次重试成功
  399. return Promise.resolve(mockOption as any);
  400. });
  401. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  402. vi.mocked(mockPage.waitForTimeout).mockResolvedValue(undefined as any);
  403. const mockLocator = {
  404. allTextContents: vi.fn().mockResolvedValue(['选项1']),
  405. };
  406. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  407. // 应该在重试后成功
  408. await selectRadixOptionAsync(mockPage, '测试下拉', '选项1');
  409. // 验证至少重试了一次
  410. expect(attemptCount).toBeGreaterThanOrEqual(2);
  411. expect(mockOption.click).toHaveBeenCalled();
  412. });
  413. it('应该在多次重试后超时并抛出错误', async () => {
  414. const mockTrigger = { click: vi.fn() };
  415. vi.mocked(mockPage.waitForSelector).mockImplementation((selector: string) => {
  416. // 触发器成功
  417. if (selector.includes('trigger') || selector.includes('label')) {
  418. return Promise.resolve(mockTrigger as any);
  419. }
  420. if (selector.includes('listbox')) {
  421. return Promise.resolve({} as any);
  422. }
  423. // 选项选择器持续失败
  424. return Promise.reject(new Error('Option not loaded'));
  425. });
  426. vi.mocked(mockPage.waitForLoadState).mockResolvedValue(undefined as any);
  427. vi.mocked(mockPage.waitForTimeout).mockResolvedValue(undefined as any);
  428. const mockLocator = {
  429. allTextContents: vi.fn().mockResolvedValue([]),
  430. };
  431. vi.mocked(mockPage.locator).mockReturnValue(mockLocator as any);
  432. // 使用短超时加快测试
  433. await expect(
  434. selectRadixOptionAsync(mockPage, '测试下拉', '选项1', { timeout: 500 })
  435. ).rejects.toThrow(E2ETestError);
  436. });
  437. });
  438. });