From 0063fcaa5bd28174ff0b4222db12e96c6e4eb8a8 Mon Sep 17 00:00:00 2001 From: awei Date: Tue, 28 Jul 2026 09:54:58 +0800 Subject: [PATCH] =?UTF-8?q?test(admin):=20=E8=A3=9C=E9=BD=8A=E5=9B=9B?= =?UTF-8?q?=E5=80=8B=E3=80=8C=E5=81=9A=E4=BA=86=E6=B2=92=E5=8B=BE=E3=80=8D?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E7=9A=84=E6=B8=AC=E8=A9=A6=EF=BC=8B=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=20TODO=20=E5=8B=BE=E9=81=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app 的待辦清單已無不需外部資源的項目,改盤點 admin 的 docs/TODO.md。 四個 [ ](登出確認、地圖點擊司機連動、司機搜尋/篩選、司機詳情頁) **其實全部早就實作了**,只是沒回填。 「做了沒勾」的真正代價不是文件難讀,而是**沒人知道它該有測試**: - DriverDetailPage 連測試檔都不存在 - FleetPage popup 的「查看司機」連結沒被覆蓋 - AppLayout 的登出確認沒被覆蓋 - DriversPage 只測過車種篩選,關鍵字搜尋沒測(placeholder 承諾姓名/電話/車牌三路) 補 10 個測試(124 → 134 passed,26 檔): - DriverDetailPage 4 案(明細/直接進入自行取資料/查不到該 id/麵包屑) - FleetPage 3 案(popup 帶連結與正確 id 且保留 href/點連結走 SPA 導向/ 點其他位置不導向)——**兩案做過反向驗證**:拿掉 popup 連結或拿掉委派 handler, 各自只讓對應那一案 FAIL - AppLayout 2 案(取消不清 session/確認才清) - DriversPage 1 案(姓名/電話/車牌大小寫不敏感三路都命中) 踩到的坑:**antd 對「剛好兩個中文字」的按鈕會插入空格**(DOM 實際是「登 出」「取 消」), getByRole name 精確比對查不到;確認框標題同時出現在 ant-modal-title 與 ant-modal-confirm-title 兩處,getByText 會 Found multiple。已在測試裡註明。 驗收:`npm test` 134 passed、`npm run lint` 乾淨、`tsc --noEmit` exit 0。 Co-Authored-By: Claude Opus 5 --- docs/TODO.md | 33 +++++++++-- src/components/AppLayout.test.tsx | 51 ++++++++++++++++- src/pages/DriverDetailPage.test.tsx | 75 +++++++++++++++++++++++++ src/pages/DriversPage.test.tsx | 38 +++++++++++++ src/pages/FleetPage.test.tsx | 87 ++++++++++++++++++++++++++++- 5 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 src/pages/DriverDetailPage.test.tsx diff --git a/docs/TODO.md b/docs/TODO.md index 518db28..eb589f1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,6 +1,6 @@ # line-fleet-admin — 缺口清單 -> 建立:2026-07-08。最後盤點:2026-07-10(以程式碼實測為準)。 +> 建立:2026-07-08。最後盤點:**2026-07-28**(以程式碼實測為準)。 > 編號沿用後端 repo 的 [gap-analysis-plan](../../line-fleet-dispatch/docs/2026-07-07-gap-analysis-plan.md)(C=後台前端、D=後端)。 > 每完成一項:實跑驗收 → 勾選回填 → commit + push(main)。 @@ -13,12 +13,22 @@ | 核心瀏覽 | ✅ 登入、營運總覽 Dashboard、即時車隊地圖、訂單列表/詳情+軌跡回放(含日期/關鍵字篩選)、司機列表、日報表(可匯出 CSV) | | 寫入操作 | ✅ 司機啟停、派單參數、強制取消(2026-07-08) | | 韌性 | ✅ 全域 Error Boundary、JWT `exp` 主動登出(2026-07-10) | -| 測試 | ✅ 19 測試檔/76 tests(含 Dashboard/tokens/csv/ErrorBoundary) | +| 測試 | ✅ **26 測試檔/134 tests**(2026-07-28;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`) | | CI | ✅ lint + test + build(`.github/workflows/ci.yml`);2026-07-10 修好 `npm ci` ERESOLVE | | 視覺驗收 | ✅ C5(2026-07-08)+ UI/UX 翻新腳本已對齊新路由(`docs/screenshots/ux-2026-07-10/`) | **一句話**:「看」已齊全;「管」核心寫入(C2/C3/強制取消)已串接 P2 API。 +### 2026-07-28 勾選稽核(過期未勾) + +本檔的四個 `[ ]`(3.3 登出確認、3.5 點擊司機連動、3.6 搜尋/篩選、3.6 司機詳情頁) +**其實全部早就實作了**,只是沒回填。逐條對照程式碼確認後補勾(各項證據見下方條目)。 + +**但其中三項先前完全沒有測試**——「做了沒勾」的代價不只是文件難讀,而是 +**沒人知道它該有測試**:司機詳情頁連測試檔都不存在、popup 連結與登出確認也都沒被覆蓋。 +同日補上 10 個測試(124 → **134 passed**),FleetPage 兩案做過反向驗證 +(拿掉 popup 連結/拿掉委派 handler,各自只讓對應那案 FAIL)。 + --- ## 一、已完成 ✅ @@ -78,7 +88,9 @@ `RequireAuth` 依 `exp` 排到期鬧鐘(delay clamp 到 2^31-1,否則 setTimeout 溢位變立即觸發), 處理「停在頁面上不發請求、沒有 401 可觸發登出」的情境。 - [x] **已登入導向** — LoginPage 已登入自動導回首頁 -- [ ] **登出確認** — 可選,避免誤觸 +- [x] **登出確認** ✅ 已做(2026-07-28 查證補勾)— `AppLayout` 的登出走 `App.useApp()` 的 + `modal.confirm`(「確定要登出?」),確認才 `clearSession()` + 導向 `/login`。 + **先前沒有任何測試**,同日補 2 案(取消不清 session/確認才清)。 ### 3.4 訂單瀏覽增強 @@ -97,15 +109,24 @@ ### 3.5 車隊地圖增強 - [x] **Marker popup 資訊** — 姓名、狀態、更新時間 -- [ ] **點擊司機連動** — 導向司機列表或詳情 +- [x] **點擊司機連動** ✅ 已做(2026-07-28 查證補勾)— popup 內「查看司機 →」, + 以**委派點擊**(`[data-driver-link]`)走 SPA 導向 `/drivers/:id`,`href` 保留當 JS 失效的退路。 + **先前沒有任何測試**,同日補 3 案(popup 帶連結與正確 id/點連結走 SPA 導向/ + 點地圖其他位置不導向);兩案都做過反向驗證(拿掉連結或拿掉 handler 各自 FAIL)。 - [x] **共用 Map 元件** — `src/components/mapStyle.ts` - [x] **地圖視野** — fitBounds 依司機點位 ### 3.6 司機管理增強 - [x] **normalizeDriver** — `fetchDrivers` 兼容 PascalCase/snake_case -- [ ] **搜尋/篩選** — 姓名、電話、狀態 -- [ ] **司機詳情頁** — `/drivers/:id`(後端若有單筆端點再串;否則用列表資料) +- [x] **搜尋/篩選** ✅ 已做(2026-07-28 查證補勾)— `Input.Search`「搜尋姓名/電話/車牌」 + (比原規劃多了車牌)+狀態下拉(含「待審核」)+車種下拉(含「未填車輛」)。 + 先前只有車種篩選有測試,同日補關鍵字搜尋 1 案(姓名/電話/**車牌大小寫不敏感**三路都驗)。 +- [x] **司機詳情頁** ✅ 已做(2026-07-28 查證補勾)— `DriverDetailPage` + 路由 `/drivers/:id`。 + **後端確實沒有單筆司機 GET**(`api/admin.ts` 只有列表/狀態/車輛審核), + 所以照原規劃的退路走列表資料:列表快取命中免再打,直接開網址則自己抓一次。 + **先前完全沒有測試**,同日補 `DriverDetailPage.test.tsx` 4 案 + (明細顯示/直接進入會自行取資料/查不到該 id 顯示「找不到司機 #999」並留兩處返回/麵包屑回列表)。 ### 3.7 報表增強 diff --git a/src/components/AppLayout.test.tsx b/src/components/AppLayout.test.tsx index 0f2ab8d..15a511e 100644 --- a/src/components/AppLayout.test.tsx +++ b/src/components/AppLayout.test.tsx @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import AppLayout from './AppLayout'; import { renderWithProviders } from '../test/render'; -import { clearSession, saveSession } from '../auth/auth'; +import { clearSession, isLoggedIn, saveSession } from '../auth/auth'; describe('AppLayout 選單分級', () => { afterEach(() => { @@ -30,3 +31,49 @@ describe('AppLayout 選單分級', () => { expect(screen.queryByText('使用者管理')).not.toBeInTheDocument(); }); }); + +// 登出鈕就在 Header 右上角、緊鄰使用者名稱,誤觸成本是整個 session 重登。 +// 這兩案的重點是「取消真的什麼都沒發生」——只驗確認路徑會讓誤觸防護形同虛設。 +describe('AppLayout 登出確認', () => { + afterEach(() => { + clearSession(); + }); + + // ⚠️ antd 會在「剛好兩個中文字」的按鈕中間插入空格(DOM 實際是「登 出」「取 消」), + // 所以 name 一律用寬鬆的 /登\s*出/,精確字串比對會查不到元素。 + // 標題同時出現在 ant-modal-title 與 ant-modal-confirm-title 兩處, + // 因此斷言走 dialog 的 textContent 而非 getByText(後者會 Found multiple)。 + it('按登出先跳確認框,按取消不清 session', async () => { + const user = userEvent.setup(); + saveSession('tok', '王小明', 'dispatcher'); + + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: /登\s*出/ })); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent('確定要登出?'); + + await user.click(await within(dialog).findByRole('button', { name: /取\s*消/ })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(isLoggedIn()).toBe(true); + }); + + it('確認後才清 session', async () => { + const user = userEvent.setup(); + saveSession('tok', '王小明', 'dispatcher'); + + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: /登\s*出/ })); + // 確認框的主鈕文案也是「登出」,取 dialog 內那顆以免點回 Header 上的原鈕 + const dialog = await screen.findByRole('dialog'); + await user.click(await within(dialog).findByRole('button', { name: /登\s*出/ })); + + await waitFor(() => { + expect(isLoggedIn()).toBe(false); + }); + }); +}); diff --git a/src/pages/DriverDetailPage.test.tsx b/src/pages/DriverDetailPage.test.tsx new file mode 100644 index 0000000..e425f32 --- /dev/null +++ b/src/pages/DriverDetailPage.test.tsx @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import DriverDetailPage from './DriverDetailPage'; +import { renderWithProviders } from '../test/render'; + +const mockFetchDrivers = vi.fn(); + +vi.mock('../api/admin', () => ({ + fetchDrivers: (...args: unknown[]) => mockFetchDrivers(...args), +})); + +const driver = { + ID: 7, + Name: '王大明', + Phone: '0912345678', + LineUserID: 'line-7', + Status: 1, + VehicleType: 'sedan', + PlateNumber: 'ABC-1234', + VehicleReviewStatus: 'approved', + VehicleReviewNote: '', + RatingAvg: 4.5, + RatingCount: 2, + CreatedAt: '2026-07-20T01:02:03Z', + UpdatedAt: '2026-07-21T04:05:06Z', +}; + +describe('DriverDetailPage', () => { + beforeEach(() => { + mockFetchDrivers.mockReset(); + mockFetchDrivers.mockResolvedValue([driver]); + }); + + it('從列表資料取出對應司機並顯示明細', async () => { + renderWithProviders(, { route: '/drivers/7', path: '/drivers/:id' }); + + // 標題用姓名,代表確實比對到了 id 而不是只印路由參數 + expect(await screen.findByText('司機:王大明')).toBeInTheDocument(); + expect(screen.getByText('0912345678')).toBeInTheDocument(); + expect(screen.getByText('line-7')).toBeInTheDocument(); + expect(screen.getByText('待命')).toBeInTheDocument(); + }); + + // 直接開網址(沒經過列表)時本頁會自己抓一次;這是它能單獨被連結的前提, + // 例如即時車隊地圖 popup 的「查看司機 →」。 + it('直接進入時自行呼叫 fetchDrivers', async () => { + renderWithProviders(, { route: '/drivers/7', path: '/drivers/:id' }); + + expect(await screen.findByText('司機:王大明')).toBeInTheDocument(); + expect(mockFetchDrivers).toHaveBeenCalled(); + }); + + // 後端無單筆端點,找不到只可能是「列表裡沒有這個 id」——要說清楚是哪個 id, + // 並留一條回列表的路,不能只給一個空白 Card。 + it('列表中沒有該 id 時顯示找不到並提供返回', async () => { + renderWithProviders(, { route: '/drivers/999', path: '/drivers/:id' }); + + expect(await screen.findByText('找不到司機 #999')).toBeInTheDocument(); + // 兩顆:PageHeader 右側固定有一顆,Empty 裡再給一顆——空狀態的視線焦點在中間, + // 只留右上角那顆會讓人以為沒有出路。 + expect(screen.getAllByRole('button', { name: /返回司機列表/ })).toHaveLength(2); + }); + + it('麵包屑可回到司機管理', async () => { + const user = userEvent.setup(); + renderWithProviders(, { route: '/drivers/7', path: '/drivers/:id' }); + + expect(await screen.findByText('司機:王大明')).toBeInTheDocument(); + const crumb = screen.getByRole('link', { name: '司機管理' }); + expect(crumb).toHaveAttribute('href', '/drivers'); + await user.click(crumb); // 不該丟例外(路由存在於 MemoryRouter) + }); +}); diff --git a/src/pages/DriversPage.test.tsx b/src/pages/DriversPage.test.tsx index 2dac6f8..2ce36d0 100644 --- a/src/pages/DriversPage.test.tsx +++ b/src/pages/DriversPage.test.tsx @@ -77,6 +77,44 @@ describe('DriversPage', () => { }); expect(screen.queryByText('寵物車司機')).not.toBeInTheDocument(); }); + + // 搜尋框寫著「搜尋姓名/電話/車牌」——**三個欄位都要真的能搜**。 + // placeholder 承諾了卻搜不到比沒有搜尋更糟:使用者會以為查無此人。 + it('關鍵字搜尋:姓名/電話/車牌三個欄位都命中', async () => { + const user = userEvent.setup(); + mockFetchDrivers.mockResolvedValue([ + { ID: 1, Name: '王大明', Phone: '0911111111', LineUserID: 'l1', Status: 1, VehicleType: 'sedan', PlateNumber: 'AAA-1111', VehicleReviewStatus: 'approved', VehicleReviewNote: '' }, + { ID: 2, Name: '李小美', Phone: '0922222222', LineUserID: 'l2', Status: 1, VehicleType: 'pet', PlateNumber: 'PET-0002', VehicleReviewStatus: 'approved', VehicleReviewNote: '' }, + ]); + + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('王大明')).toBeInTheDocument(); + }); + + const search = screen.getByPlaceholderText('搜尋姓名/電話/車牌'); + + await user.type(search, '李小'); + await waitFor(() => { + expect(screen.queryByText('王大明')).not.toBeInTheDocument(); + }); + expect(screen.getByText('李小美')).toBeInTheDocument(); + + await user.clear(search); + await user.type(search, '0911'); + await waitFor(() => { + expect(screen.queryByText('李小美')).not.toBeInTheDocument(); + }); + expect(screen.getByText('王大明')).toBeInTheDocument(); + + // 車牌大小寫不該影響命中——路邊比對車牌時沒人在意大小寫 + await user.clear(search); + await user.type(search, 'pet-0002'); + await waitFor(() => { + expect(screen.queryByText('王大明')).not.toBeInTheDocument(); + }); + expect(screen.getByText('李小美')).toBeInTheDocument(); + }); }); describe('DriversPage 評價欄(B5)', () => { diff --git a/src/pages/FleetPage.test.tsx b/src/pages/FleetPage.test.tsx index 8dc25c9..5d35b07 100644 --- a/src/pages/FleetPage.test.tsx +++ b/src/pages/FleetPage.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import FleetPage from './FleetPage'; import { renderWithProviders } from '../test/render'; @@ -7,6 +8,19 @@ import { renderWithProviders } from '../test/render'; const mockFetchFleet = vi.fn(); const mockFetchDrivers = vi.fn(); +// maplibre 被 mock 掉,popup 的 HTML 不會真的進 DOM——所以把它記下來直接驗字串, +// 並記住地圖容器(委派點擊掛在它身上),才能測到「點 popup 連結會導向詳情頁」。 +const captured = vi.hoisted(() => ({ + container: null as HTMLElement | null, + popupHtml: [] as string[], +})); +const navigateSpy = vi.hoisted(() => vi.fn()); + +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigateSpy, +})); + vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({})); vi.mock('maplibre-gl', () => { const map = { @@ -18,16 +32,29 @@ vi.mock('maplibre-gl', () => { const marker = { setLngLat: vi.fn().mockReturnThis(), setPopup: vi.fn().mockReturnThis(), - getPopup: vi.fn(() => ({ setHTML: vi.fn() })), + getPopup: vi.fn(() => ({ + setHTML: vi.fn((html: string) => { + captured.popupHtml.push(html); + }), + })), addTo: vi.fn().mockReturnThis(), remove: vi.fn(), }; return { default: { - Map: vi.fn(() => map), + Map: vi.fn((opts: { container: HTMLElement }) => { + captured.container = opts.container; + return map; + }), Marker: vi.fn(() => marker), Popup: vi.fn(function Popup() { - return { setHTML: vi.fn().mockReturnThis(), setText: vi.fn().mockReturnThis() }; + return { + setHTML: vi.fn(function (this: unknown, html: string) { + captured.popupHtml.push(html); + return this; + }), + setText: vi.fn().mockReturnThis(), + }; }), LngLatBounds: vi.fn(function LngLatBounds() { return { extend: vi.fn().mockReturnThis() }; @@ -52,6 +79,9 @@ vi.mock('../api/admin', () => ({ describe('FleetPage', () => { beforeEach(() => { + captured.container = null; + captured.popupHtml = []; + navigateSpy.mockReset(); mockFetchFleet.mockReset(); mockFetchDrivers.mockReset(); mockFetchFleet.mockResolvedValue([{ driver_id: 1, lat: 25.03, lng: 121.56, updated_at: 100 }]); @@ -72,4 +102,55 @@ describe('FleetPage', () => { expect(mockFetchFleet).toHaveBeenCalled(); expect(mockFetchDrivers).toHaveBeenCalled(); }); + + // popup 是「地圖 → 司機詳情」唯一的入口。它是手組的 HTML 字串, + // 沒有型別保護,改動 popup 內容時最容易把連結弄掉,所以直接驗字串。 + it('marker popup 帶「查看司機」連結與正確的 driver id', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(captured.popupHtml.length).toBeGreaterThan(0); + }); + + const html = captured.popupHtml.join('\n'); + expect(html).toContain('查看司機'); + expect(html).toContain('data-driver-link="1"'); + // href 要留著:委派點擊靠 JS,JS 掛掉時整頁跳轉是唯一退路 + expect(html).toContain('href="/drivers/1"'); + expect(html).toContain('測試司機'); + }); + + it('點 popup 的連結走 SPA 導向而不是整頁跳轉', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(captured.container).not.toBeNull(); + }); + + // maplibre 被 mock,popup 不會真的掛進 DOM;此處還原它的效果—— + // 把同一段 HTML 放進地圖容器,驗證委派 handler 真的接得到。 + const container = captured.container as HTMLElement; + container.insertAdjacentHTML( + 'beforeend', + '查看司機 →', + ); + + const link = container.querySelector('[data-driver-link]') as HTMLElement; + await userEvent.click(link); + + expect(navigateSpy).toHaveBeenCalledWith('/drivers/1'); + }); + + // 反向確認:容器內其他地方的點擊不該觸發導向,否則整張地圖都變成連結 + it('點地圖其他位置不會導向', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(captured.container).not.toBeNull(); + }); + + await userEvent.click(captured.container as HTMLElement); + + expect(navigateSpy).not.toHaveBeenCalled(); + }); });