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>
99 lines
3.4 KiB
TypeScript
99 lines
3.4 KiB
TypeScript
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', () => {
|
||
// 邊界 case:driver 端 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();
|
||
});
|
||
});
|