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>
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
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.open(mailto:)
|
||
*/
|
||
|
||
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 warning(role="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('顯示 errorCode(mono 字型小字)', () => {
|
||
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);
|
||
});
|
||
});
|