locations.test.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { render, screen, waitFor } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import '@testing-library/jest-dom';
  5. import { LocationsPage } from '@/client/admin/pages/Locations';
  6. import { AdminTestWrapper } from '~/utils/client/test-render';
  7. // Import mocked modules
  8. import { locationClient } from '@/client/api';
  9. // Mock next-themes 组件
  10. vi.mock('next-themes', () => ({
  11. ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
  12. useTheme: () => ({
  13. theme: 'light',
  14. setTheme: vi.fn(),
  15. }),
  16. }));
  17. // Mock API 客户端
  18. vi.mock('@/client/api', () => ({
  19. locationClient: {
  20. $get: vi.fn().mockResolvedValue({
  21. status: 200,
  22. ok: true,
  23. json: async () => ({
  24. data: [
  25. {
  26. id: 1,
  27. name: '北京天安门',
  28. address: '北京市东城区天安门广场',
  29. province: {
  30. id: 1,
  31. name: '北京市',
  32. code: '110000'
  33. },
  34. city: {
  35. id: 2,
  36. name: '北京市',
  37. code: '110100'
  38. },
  39. district: {
  40. id: 3,
  41. name: '东城区',
  42. code: '110101'
  43. },
  44. latitude: 39.9042,
  45. longitude: 116.4074,
  46. isDisabled: 0,
  47. createdAt: '2024-01-01T00:00:00.000Z',
  48. updatedAt: '2024-01-01T00:00:00.000Z'
  49. },
  50. {
  51. id: 2,
  52. name: '上海外滩',
  53. address: '上海市黄浦区中山东一路',
  54. province: {
  55. id: 4,
  56. name: '上海市',
  57. code: '310000'
  58. },
  59. city: {
  60. id: 5,
  61. name: '上海市',
  62. code: '310100'
  63. },
  64. district: {
  65. id: 6,
  66. name: '黄浦区',
  67. code: '310101'
  68. },
  69. latitude: 31.2304,
  70. longitude: 121.4737,
  71. isDisabled: 0,
  72. createdAt: '2024-01-01T00:00:00.000Z',
  73. updatedAt: '2024-01-01T00:00:00.000Z'
  74. }
  75. ],
  76. pagination: {
  77. total: 2,
  78. current: 1,
  79. pageSize: 20
  80. }
  81. })
  82. }),
  83. $post: vi.fn().mockResolvedValue({
  84. status: 201,
  85. ok: true,
  86. json: async () => ({
  87. id: 3,
  88. name: '新建地点',
  89. address: '新建地址',
  90. province: null,
  91. city: null,
  92. district: null,
  93. latitude: null,
  94. longitude: null,
  95. isDisabled: 0,
  96. createdAt: '2024-01-01T00:00:00.000Z'
  97. })
  98. }),
  99. ':id': {
  100. $put: vi.fn().mockResolvedValue({
  101. status: 200,
  102. ok: true,
  103. json: async () => ({
  104. id: 1,
  105. name: '更新后的地点',
  106. address: '更新后的地址',
  107. province: {
  108. id: 1,
  109. name: '北京市',
  110. code: '110000'
  111. },
  112. city: {
  113. id: 2,
  114. name: '北京市',
  115. code: '110100'
  116. },
  117. district: {
  118. id: 3,
  119. name: '东城区',
  120. code: '110101'
  121. },
  122. latitude: 39.9042,
  123. longitude: 116.4074,
  124. isDisabled: 0
  125. })
  126. }),
  127. $delete: vi.fn().mockResolvedValue({
  128. status: 204,
  129. ok: true
  130. })
  131. }
  132. },
  133. areaClient: {
  134. $get: vi.fn().mockResolvedValue({
  135. status: 200,
  136. ok: true,
  137. json: async () => ({
  138. data: [
  139. {
  140. id: 1,
  141. name: '华北地区',
  142. code: 'north_china',
  143. isDisabled: 0,
  144. createdAt: '2024-01-01T00:00:00.000Z'
  145. },
  146. {
  147. id: 2,
  148. name: '华东地区',
  149. code: 'east_china',
  150. isDisabled: 0,
  151. createdAt: '2024-01-01T00:00:00.000Z'
  152. }
  153. ],
  154. pagination: {
  155. total: 2,
  156. current: 1,
  157. pageSize: 100
  158. }
  159. })
  160. })
  161. }
  162. }));
  163. describe('LocationsPage 集成测试', () => {
  164. const user = userEvent.setup();
  165. beforeEach(() => {
  166. vi.clearAllMocks();
  167. // 模拟缺失的 Pointer Events API
  168. if (!Element.prototype.hasPointerCapture) {
  169. Element.prototype.hasPointerCapture = vi.fn(() => false);
  170. }
  171. if (!Element.prototype.releasePointerCapture) {
  172. Element.prototype.releasePointerCapture = vi.fn();
  173. }
  174. });
  175. it('应该正确渲染地点管理页面标题', async () => {
  176. render(
  177. <AdminTestWrapper>
  178. <LocationsPage />
  179. </AdminTestWrapper>
  180. );
  181. expect(screen.getByText('地点管理')).toBeInTheDocument();
  182. expect(screen.getByText('新建地点')).toBeInTheDocument();
  183. });
  184. it('应该显示地点列表和搜索功能', async () => {
  185. render(
  186. <AdminTestWrapper>
  187. <LocationsPage />
  188. </AdminTestWrapper>
  189. );
  190. // 等待数据加载
  191. await waitFor(() => {
  192. expect(screen.getByPlaceholderText('搜索地点名称或地址...')).toBeInTheDocument();
  193. });
  194. expect(screen.getByText('地点列表')).toBeInTheDocument();
  195. // 等待数据正确加载
  196. await waitFor(() => {
  197. const countText = screen.getByText(/当前共有 \d+ 条记录/);
  198. expect(countText).toBeInTheDocument();
  199. }, { timeout: 5000 });
  200. });
  201. it('应该处理搜索功能', async () => {
  202. render(
  203. <AdminTestWrapper>
  204. <LocationsPage />
  205. </AdminTestWrapper>
  206. );
  207. const searchInput = screen.getByPlaceholderText('搜索地点名称或地址...');
  208. // 输入搜索关键词
  209. await user.type(searchInput, '北京');
  210. // 等待防抖搜索生效
  211. await waitFor(() => {
  212. expect(searchInput).toHaveValue('北京');
  213. });
  214. });
  215. it('应该显示区域筛选功能', async () => {
  216. const user = userEvent.setup();
  217. render(
  218. <AdminTestWrapper>
  219. <LocationsPage />
  220. </AdminTestWrapper>
  221. );
  222. // 等待数据加载
  223. await waitFor(() => {
  224. expect(screen.getByText('地点列表')).toBeInTheDocument();
  225. });
  226. // 验证区域筛选器存在
  227. const areaFilter = screen.getByPlaceholderText('选择区域');
  228. expect(areaFilter).toBeInTheDocument();
  229. // 点击Select来展开选项
  230. await user.click(areaFilter);
  231. // 验证筛选选项存在 - 检查选项数量
  232. const options = screen.getAllByText(/华北地区|华东地区/);
  233. expect(options.length).toBeGreaterThanOrEqual(2);
  234. });
  235. it('应该显示状态筛选功能', async () => {
  236. const user = userEvent.setup();
  237. render(
  238. <AdminTestWrapper>
  239. <LocationsPage />
  240. </AdminTestWrapper>
  241. );
  242. // 等待数据加载
  243. await waitFor(() => {
  244. expect(screen.getByText('地点列表')).toBeInTheDocument();
  245. });
  246. // 验证状态筛选器存在
  247. const statusFilter = screen.getByPlaceholderText('状态筛选');
  248. expect(statusFilter).toBeInTheDocument();
  249. // 点击Select来展开选项
  250. await user.click(statusFilter);
  251. // 验证筛选选项存在
  252. expect(screen.getByText('全部状态')).toBeInTheDocument();
  253. expect(screen.getByText('启用')).toBeInTheDocument();
  254. expect(screen.getByText('禁用')).toBeInTheDocument();
  255. });
  256. it('应该显示创建地点按钮并打开模态框', async () => {
  257. render(
  258. <AdminTestWrapper>
  259. <LocationsPage />
  260. </AdminTestWrapper>
  261. );
  262. // 等待数据加载
  263. await waitFor(() => {
  264. expect(screen.getByText('新建地点')).toBeInTheDocument();
  265. });
  266. const createButton = screen.getByRole('button', { name: /新建地点/i });
  267. await user.click(createButton);
  268. // 验证模态框标题
  269. expect(screen.getByRole('heading', { name: '新建地点' })).toBeInTheDocument();
  270. });
  271. it('应该显示分页组件', async () => {
  272. render(
  273. <AdminTestWrapper>
  274. <LocationsPage />
  275. </AdminTestWrapper>
  276. );
  277. // 验证分页控件存在 - 等待数据加载
  278. await waitFor(() => {
  279. expect(screen.getByText('显示第 1 到 2 条,共 2 条记录')).toBeInTheDocument();
  280. });
  281. });
  282. it('应该处理表格数据加载状态', async () => {
  283. render(
  284. <AdminTestWrapper>
  285. <LocationsPage />
  286. </AdminTestWrapper>
  287. );
  288. // 等待数据加载完成
  289. await waitFor(() => {
  290. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  291. expect(screen.getByText('上海外滩')).toBeInTheDocument();
  292. });
  293. });
  294. it('应该显示正确的表格列标题', async () => {
  295. render(
  296. <AdminTestWrapper>
  297. <LocationsPage />
  298. </AdminTestWrapper>
  299. );
  300. // 等待数据加载
  301. await waitFor(() => {
  302. expect(screen.getByText('地点名称')).toBeInTheDocument();
  303. expect(screen.getByText('地址')).toBeInTheDocument();
  304. expect(screen.getByText('所属区域')).toBeInTheDocument();
  305. expect(screen.getByText('坐标')).toBeInTheDocument();
  306. expect(screen.getByText('状态')).toBeInTheDocument();
  307. expect(screen.getByText('创建时间')).toBeInTheDocument();
  308. expect(screen.getByText('操作')).toBeInTheDocument();
  309. });
  310. });
  311. it('应该显示地点数据在表格中', async () => {
  312. render(
  313. <AdminTestWrapper>
  314. <LocationsPage />
  315. </AdminTestWrapper>
  316. );
  317. // 等待数据加载完成
  318. await waitFor(() => {
  319. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  320. expect(screen.getByText('上海外滩')).toBeInTheDocument();
  321. expect(screen.getByText('北京市东城区天安门广场')).toBeInTheDocument();
  322. expect(screen.getByText('上海市黄浦区中山东一路')).toBeInTheDocument();
  323. expect(screen.getAllByText('启用').length).toBeGreaterThan(0);
  324. });
  325. });
  326. it('应该显示所属区域信息', async () => {
  327. render(
  328. <AdminTestWrapper>
  329. <LocationsPage />
  330. </AdminTestWrapper>
  331. );
  332. // 等待数据加载完成
  333. await waitFor(() => {
  334. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  335. });
  336. // 验证区域信息显示
  337. expect(screen.getByText('北京市')).toBeInTheDocument();
  338. expect(screen.getByText('东城区')).toBeInTheDocument();
  339. });
  340. it('应该显示坐标信息', async () => {
  341. render(
  342. <AdminTestWrapper>
  343. <LocationsPage />
  344. </AdminTestWrapper>
  345. );
  346. // 等待数据加载完成
  347. await waitFor(() => {
  348. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  349. });
  350. // 验证坐标显示
  351. expect(screen.getByText('39.904200, 116.407400')).toBeInTheDocument();
  352. });
  353. it('应该包含编辑、启用/禁用和删除操作按钮', async () => {
  354. render(
  355. <AdminTestWrapper>
  356. <LocationsPage />
  357. </AdminTestWrapper>
  358. );
  359. // 等待数据加载完成
  360. await waitFor(() => {
  361. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  362. });
  363. // 查找操作按钮
  364. const actionButtons = screen.getAllByRole('button');
  365. const hasActionButtons = actionButtons.some(button =>
  366. button.textContent?.includes('禁用') ||
  367. button.textContent?.includes('启用') ||
  368. button.innerHTML.includes('edit') ||
  369. button.innerHTML.includes('trash')
  370. );
  371. expect(hasActionButtons).toBe(true);
  372. });
  373. it('应该处理启用/禁用地点操作', async () => {
  374. const user = userEvent.setup();
  375. render(
  376. <AdminTestWrapper>
  377. <LocationsPage />
  378. </AdminTestWrapper>
  379. );
  380. await waitFor(() => {
  381. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  382. });
  383. // 查找启用/禁用按钮
  384. const toggleButtons = screen.getAllByRole('button').filter(btn =>
  385. btn.textContent?.includes('禁用') || btn.textContent?.includes('启用')
  386. );
  387. if (toggleButtons.length > 0) {
  388. // 模拟确认对话框
  389. window.confirm = vi.fn().mockReturnValue(true);
  390. await user.click(toggleButtons[0]);
  391. // 验证确认对话框被调用
  392. expect(window.confirm).toHaveBeenCalledWith('确定要禁用这个地点吗?');
  393. }
  394. });
  395. it('应该处理删除地点操作', async () => {
  396. const user = userEvent.setup();
  397. render(
  398. <AdminTestWrapper>
  399. <LocationsPage />
  400. </AdminTestWrapper>
  401. );
  402. await waitFor(() => {
  403. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  404. });
  405. // 查找删除按钮
  406. const deleteButtons = screen.getAllByRole('button').filter(btn =>
  407. btn.innerHTML.includes('trash') || btn.getAttribute('aria-label')?.includes('delete')
  408. );
  409. if (deleteButtons.length > 0) {
  410. // 模拟确认对话框
  411. window.confirm = vi.fn().mockReturnValue(true);
  412. await user.click(deleteButtons[0]);
  413. // 验证确认对话框被调用
  414. expect(window.confirm).toHaveBeenCalledWith('确定要删除这个地点吗?');
  415. }
  416. });
  417. it('应该处理区域筛选', async () => {
  418. const user = userEvent.setup();
  419. render(
  420. <AdminTestWrapper>
  421. <LocationsPage />
  422. </AdminTestWrapper>
  423. );
  424. await waitFor(() => {
  425. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  426. });
  427. // 验证区域筛选器存在
  428. const areaFilter = screen.getByPlaceholderText('选择区域');
  429. expect(areaFilter).toBeInTheDocument();
  430. // 点击Select来展开选项
  431. await user.click(areaFilter);
  432. // 验证筛选选项存在 - 检查选项数量
  433. const options = screen.getAllByText(/华北地区|华东地区/);
  434. expect(options.length).toBeGreaterThanOrEqual(2);
  435. });
  436. it('应该处理状态筛选', async () => {
  437. const user = userEvent.setup();
  438. render(
  439. <AdminTestWrapper>
  440. <LocationsPage />
  441. </AdminTestWrapper>
  442. );
  443. await waitFor(() => {
  444. expect(screen.getByText('北京天安门')).toBeInTheDocument();
  445. });
  446. // 验证状态筛选器存在
  447. const statusFilter = screen.getByPlaceholderText('状态筛选');
  448. expect(statusFilter).toBeInTheDocument();
  449. // 点击Select来展开选项
  450. await user.click(statusFilter);
  451. // 验证筛选选项存在
  452. expect(screen.getByText('全部状态')).toBeInTheDocument();
  453. expect(screen.getByText('启用')).toBeInTheDocument();
  454. expect(screen.getByText('禁用')).toBeInTheDocument();
  455. });
  456. it('应该处理API错误场景', async () => {
  457. // 模拟API错误
  458. (locationClient.$get as any).mockResolvedValueOnce({
  459. status: 500,
  460. ok: false,
  461. json: async () => ({ error: 'Internal server error' })
  462. });
  463. render(
  464. <AdminTestWrapper>
  465. <LocationsPage />
  466. </AdminTestWrapper>
  467. );
  468. // 验证页面仍然渲染基本结构
  469. expect(screen.getByText('地点管理')).toBeInTheDocument();
  470. expect(screen.getByText('新建地点')).toBeInTheDocument();
  471. // 验证错误处理(组件应该优雅处理错误)
  472. await waitFor(() => {
  473. expect(screen.queryByText('北京天安门')).not.toBeInTheDocument();
  474. });
  475. });
  476. it('应该显示空状态', async () => {
  477. // 模拟空数据
  478. (locationClient.$get as any).mockResolvedValueOnce({
  479. status: 200,
  480. ok: true,
  481. json: async () => ({
  482. data: [],
  483. pagination: {
  484. total: 0,
  485. current: 1,
  486. pageSize: 20
  487. }
  488. })
  489. });
  490. render(
  491. <AdminTestWrapper>
  492. <LocationsPage />
  493. </AdminTestWrapper>
  494. );
  495. // 验证空状态显示
  496. await waitFor(() => {
  497. expect(screen.getByText('暂无地点数据')).toBeInTheDocument();
  498. });
  499. });
  500. it('应该显示加载状态', async () => {
  501. // 模拟加载延迟
  502. (locationClient.$get as any).mockImplementationOnce(() =>
  503. new Promise(resolve => setTimeout(() => resolve({
  504. status: 200,
  505. ok: true,
  506. json: async () => ({
  507. data: [],
  508. pagination: { total: 0, current: 1, pageSize: 20 }
  509. })
  510. }), 100))
  511. );
  512. render(
  513. <AdminTestWrapper>
  514. <LocationsPage />
  515. </AdminTestWrapper>
  516. );
  517. // 验证加载状态显示
  518. expect(screen.getByText('加载中...')).toBeInTheDocument();
  519. });
  520. });