visionA/local-tool/frontend/src/tests/components/firmware-error-view.test.tsx
jim800121chen 06ff2fe987 feat(local-tool): M9-4 — Frontend FW badge + 升級 modal + WS hot-fix
A 階段第四個 milestone、完整 Frontend FW UI(badge / modal / 8 種 reason 復原)+ backend WS hot-fix(補對稱於 flash 的 firmware WS endpoint)。

Frontend(13 修改 / 7 新檔):
- 新 firmware/ component group (badge / upgrade-button / upgrade-dialog 4-phase / progress-view / error-view 8-reason / index)
- Zustand store (firmware-store.ts) + WS hook (use-firmware-progress.ts) 對齊既有 useFlashProgress pattern
- DeviceCard 整合 FirmwareBadge + FirmwareUpgradeButton
- i18n: settings.firmware.* namespace (對齊 Design Spec §9 SoT) + devices.card.fwBadge.* (zh-TW + en, 57 leaf keys × 2 lang = 114 strings)
- toast.ts ToastOptions interface (duration param)
- types/device.ts: FW 衍生欄位 + FirmwareStage/Reason/ProgressEvent/ActiveTask types

Backend WS hot-fix (3 檔):
- ws/firmware_ws.go (50 行、純對稱 flash_ws.go)
- ws/firmware_ws_test.go (165 行、2 smoke tests: broadcast + room isolation)
- router.go: GET /ws/devices/:id/firmware-progress

關鍵設計:
- R-FW-11 緩解: upgrading phase modal 不可關 (onInteractOutside/onEscapeKeyDown preventDefault + 隱藏 X)
- 多裝置隔離 defense in depth: store handleEvent activeDeviceId mismatch 直接 return
- 8 種 reason → 4 種 UX (recoverable/destructive/brick 警告/contactSupport)
- ContactSupport mailto handler (RFC 6068 + encodeURIComponent)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 3 Major / 8 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 2 Suggestion(接受方案 A、不需 frontend 第 3 輪)
- MJ1 i18n namespace 採方案 A (settings.firmware.*)、Design SoT 優先、Reviewer 同意

測試:
- pnpm test --run: 60 tests pass (32 firmware: 22 store + 10 badge + 新 9 error-view + 19 既有)
- npx tsc --noEmit: 0 error
- pnpm build: production build 成功
- go test ./internal/api/ws/... -race: 1.964s 全綠
- pnpm lint firmware/: 0 hit (17 既有 lint 問題不屬 M9-4、follow-up)

未做(範圍外):
- Settings 韌體面板 (M9-12 B 階段)
- 手動降版 UI (M9-12)
- 版本切換 dropdown (B 階段)
- Wails 控制台 force-quit modal (M9-4.5)

A 階段 MVP 後端 + 前端開發全部完成、剩 M9-4.5 (SIGTERM + Wails OnBeforeClose) + M9-5 (三平台實機驗證)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:57:21 +08:00

165 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { FirmwareErrorView } from '@/components/firmware/firmware-error-view';
import type { FirmwareProgressEvent, FirmwareReason } from '@/types/device';
/**
* M9-4 第 2 輪修改補測Reviewer M5
* 驗證 destructive vs recoverable reason 在 FirmwareErrorView 的 UI 差異。
*
* 涵蓋:
* - destructive reason 顯示 brick warning
* - destructive reason 不顯示 Retry 按鈕、改顯示 ContactSupport 按鈕enabled、Reviewer M6 已修)
* - recoverable reason 顯示 Retry 按鈕onRetry 有傳)
* - errorCode 顯示
* - 技術資訊 collapsible 預設收合
* - ContactSupport 按鈕點擊會 trigger window.openmailto:
*/
function makeEvent(overrides: Partial<FirmwareProgressEvent> = {}): FirmwareProgressEvent {
return {
type: 'firmware_progress',
deviceId: 'dev-1',
stage: 'error',
percent: -1,
elapsedMs: 12000,
errorCode: 'FW_E102',
rawError: 'kp_update_kdp_firmware: timeout',
...overrides,
};
}
beforeEach(() => {
// window.open mock — JSDOM 預設有 window.open、但回傳 null不會真開新分頁
vi.spyOn(window, 'open').mockImplementation(() => null);
});
describe('FirmwareErrorView — destructive reasons (brick warning + ContactSupport)', () => {
const destructiveReasons: FirmwareReason[] = [
'disconnect_during_op',
'verify_mismatch',
'verify_not_found',
];
it.each(destructiveReasons)(
'reason=%s 顯示 brick warning + ContactSupport不顯示 Retry',
(reason) => {
const onClose = vi.fn();
const onRetry = vi.fn();
render(
<FirmwareErrorView
progress={makeEvent({ reason })}
onRetry={onRetry}
onClose={onClose}
/>,
);
// brick warningrole="note"
const note = screen.getByRole('note');
expect(note.textContent).toMatch(/損毀|damaged/i);
// 不顯示 Retry / ReplugRetry / Rescan 按鈕
// ContactSupport 為 destructive variant、唯一 primary action
const buttons = screen.getAllByRole('button');
const labels = buttons.map((b) => b.textContent || '');
expect(labels.some((l) => /Retry|重試/.test(l))).toBe(false);
expect(labels.some((l) => /Contact|聯絡技術支援|Support/.test(l))).toBe(true);
},
);
it('ContactSupport 按鈕點擊會開 mailto: handler不再 disabled', () => {
const onClose = vi.fn();
render(
<FirmwareErrorView
progress={makeEvent({ reason: 'verify_mismatch' })}
onClose={onClose}
/>,
);
const contactBtn = screen.getByRole('button', { name: /Contact|聯絡技術支援|Support/ });
expect((contactBtn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(contactBtn);
expect(window.open).toHaveBeenCalled();
const [href] = (window.open as ReturnType<typeof vi.fn>).mock.calls[0];
expect(typeof href).toBe('string');
expect(href).toMatch(/^mailto:/);
// body 應該帶上技術資訊
expect(decodeURIComponent(href)).toContain('stage: error');
expect(decodeURIComponent(href)).toContain('reason: verify_mismatch');
});
});
describe('FirmwareErrorView — recoverable reasons show Retry button', () => {
it('reason=connect_failed 顯示 Retry 按鈕、不顯示 brick warning', () => {
const onClose = vi.fn();
const onRetry = vi.fn();
render(
<FirmwareErrorView
progress={makeEvent({ reason: 'connect_failed' })}
onRetry={onRetry}
onClose={onClose}
/>,
);
// 沒有 brick warning role=note
expect(screen.queryByRole('note')).toBeNull();
// 顯示 Retry primary action 按鈕
const retryBtn = screen.getByRole('button', { name: /Retry|重試|插拔/ });
expect(retryBtn).toBeTruthy();
fireEvent.click(retryBtn);
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('reason=scan_not_found 顯示 ReplugRetry 動作', () => {
const onClose = vi.fn();
const onRetry = vi.fn();
render(
<FirmwareErrorView
progress={makeEvent({ reason: 'scan_not_found' })}
onRetry={onRetry}
onClose={onClose}
/>,
);
const btn = screen.getByRole('button', { name: /插拔|Unplug and retry/ });
expect(btn).toBeTruthy();
});
});
describe('FirmwareErrorView — common UI elements', () => {
it('顯示 errorCodemono 字型小字)', () => {
render(
<FirmwareErrorView
progress={makeEvent({ errorCode: 'FW_E102', reason: 'upgrade_mid_failed' })}
onClose={vi.fn()}
/>,
);
// errorCode 同時出現在「錯誤代碼」<p> 與技術資訊 <pre>、用 getAllByText 確保至少 1 個。
expect(screen.getAllByText(/FW_E102/).length).toBeGreaterThanOrEqual(1);
});
it('技術資訊 collapsible 預設收合', () => {
const { container } = render(
<FirmwareErrorView progress={makeEvent({ reason: 'connect_failed' })} onClose={vi.fn()} />,
);
const details = container.querySelector('details');
expect(details).not.toBeNull();
expect((details as HTMLDetailsElement).open).toBe(false);
});
it('Close 按鈕觸發 onClose callback', () => {
const onClose = vi.fn();
render(
<FirmwareErrorView
progress={makeEvent({ reason: 'connect_failed' })}
onRetry={vi.fn()}
onClose={onClose}
/>,
);
const closeBtn = screen.getByRole('button', { name: /Close|關閉/ });
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
});