visionA/local-tool/frontend/src/tests/components/firmware-badge.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

99 lines
3.4 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 } from 'vitest';
import { render, screen } from '@testing-library/react';
import { computeBadgeState, FirmwareBadge } from '@/components/firmware/firmware-badge';
import type { Device } from '@/types/device';
function makeDevice(overrides: Partial<Device> = {}): Device {
return {
id: 'dev-1',
name: 'KL520 #1',
type: 'kneron_kl520',
port: '/dev/usb0',
status: 'connected',
firmwareVersion: 'v2.2.0',
bundledFirmwareVersion: 'v2.2.0',
firmwareIsLegacy: false,
firmwareCanUpgrade: false,
...overrides,
};
}
describe('computeBadgeState — AC-FW-1.1 4-color rule', () => {
it('returns legacy when firmwareIsLegacy is true', () => {
expect(
computeBadgeState(
makeDevice({ firmwareIsLegacy: true, firmwareVersion: 'KDP1' }),
),
).toBe('legacy');
});
it('returns unknown when firmwareVersion is missing', () => {
expect(computeBadgeState(makeDevice({ firmwareVersion: '' }))).toBe('unknown');
});
it('returns unknown when bundledFirmwareVersion is missing or "unknown"', () => {
expect(
computeBadgeState(makeDevice({ bundledFirmwareVersion: '' })),
).toBe('unknown');
expect(
computeBadgeState(makeDevice({ bundledFirmwareVersion: 'unknown' })),
).toBe('unknown');
});
it('returns current when firmwareVersion equals bundled', () => {
expect(computeBadgeState(makeDevice())).toBe('current');
});
it('returns older when both KDP2 but versions differ', () => {
expect(
computeBadgeState(
makeDevice({ firmwareVersion: 'KDP2.1.0', bundledFirmwareVersion: 'KDP2.2.0' }),
),
).toBe('older');
});
it('legacy precedence: if isLegacy=true, even with matching version returns legacy', () => {
// 邊界 casedriver 端 isLegacy=true 但 firmwareVersion 又恰好等於 bundled
// 理論不會發生、但若發生、isLegacy 優先)。
expect(
computeBadgeState(
makeDevice({ firmwareIsLegacy: true, firmwareVersion: 'v2.2.0' }),
),
).toBe('legacy');
});
});
describe('FirmwareBadge — renders correct state + a11y', () => {
it('renders with data-state attribute matching computeBadgeState', () => {
const { container } = render(<FirmwareBadge device={makeDevice()} />);
const el = container.querySelector('[data-testid="firmware-badge"]');
expect(el).not.toBeNull();
expect(el?.getAttribute('data-state')).toBe('current');
});
it('renders legacy badge with KDP1 label', () => {
render(
<FirmwareBadge
device={makeDevice({ firmwareIsLegacy: true, firmwareVersion: 'KDP1' })}
/>,
);
const badge = screen.getByTestId('firmware-badge');
expect(badge.getAttribute('data-state')).toBe('legacy');
// 顯示 KDP1 文字(不是版本字串)
expect(badge.textContent).toContain('KDP1');
});
it('renders unknown badge when firmwareVersion is missing', () => {
render(<FirmwareBadge device={makeDevice({ firmwareVersion: '' })} />);
const badge = screen.getByTestId('firmware-badge');
expect(badge.getAttribute('data-state')).toBe('unknown');
});
it('has aria-label and title (tooltip)', () => {
render(<FirmwareBadge device={makeDevice()} />);
const badge = screen.getByTestId('firmware-badge');
expect(badge.getAttribute('aria-label')).toBeTruthy();
expect(badge.getAttribute('title')).toBeTruthy();
});
});