visionA/local-tool/server/internal/api/handlers/firmware_handler_test.go
jim800121chen 5e281ed449 feat(local-tool): M9-3 — firmware API handlers + WebSocket progress room
A 階段第三個 milestone、暴露 firmware service 給 Frontend / Wails control panel。

New / modified:
- server/internal/api/handlers/firmware_handler.go: 新檔 465 行(upgrade + active-tasks endpoint + WS broadcast goroutine)
- server/internal/api/handlers/firmware_handler_test.go: 新檔 938 行、26+ subtests
- server/internal/api/handlers/device_handler.go: +47 行(3 個 firmware 衍生欄位)
- server/internal/api/router.go: +23 行
- server/main.go: +10 行(wire firmware service + handler)

4 endpoints 全到位(對齊 TDD §3.1):
- GET /api/devices: 加 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion(firmwareVersion 沿用既有 DeviceInfo 鍵)
- POST /api/devices/scan: 同步走 enrichDevices
- POST /api/devices/:id/firmware/upgrade: 202 + {taskId}
- GET /api/firmware/active-tasks: HasActiveTask + GetActiveTaskInfo
- WebSocket room firmware:<deviceID> broadcast 對齊 §4.2

關鍵設計:
- 3 層 interface(firmwareBroadcaster / firmwareService / deviceLookupSource)+ DeviceManagerAdapter 解 import cycle
- bundledVersion cache(只 cache success、避免 thundering herd / poison)
- isLegacyFirmware 對齊 bridge.py 規則(legacy_exact set + KDP1.x prefix + KDP2-9 forward-compat)+ parity 真值表測試
- 5 個錯誤碼齊全(DEVICE_NOT_FOUND / FW_UNSUPPORTED_CHIP / FW_DEVICE_BUSY / FW_UPGRADE_FAILED / FW_UPGRADE_BRICK_RISK)

Reviewer 兩輪審查:
- Round 1: 0 Critical / 1 Major / 3 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 0 Minor / 3 極小 Suggestion(全部 backend 不需處理、純評估)
- Major 1(JSON 雙鍵衝突 firmwareVer vs firmwareVersion)方案 A 完全到位、3 個 test 鎖定 regression

TDD 同步:firmware-management.md §3.1 line 131 firmwareVer → firmwareVersion 對齊實作。

測試:go test ./... -race -count=1 全綠(handlers 2.489s / api 3.522s / ws 4.623s / device 1.931s / firmware 2.695s / driver/kneron 5.583s / model 5.022s)

SIGTERM main.go 整合留 M9-4.5(與 Wails OnBeforeClose 一起做)。

下一步:M9-4 Frontend Devices 頁 FW badge + 升級 modal + i18n(1.5 人天)

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

939 lines
32 KiB
Go
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.

package handlers
// firmware_handler_test.go — M9-3 unit test
//
// 覆蓋:
// 1. POST /api/devices/:id/firmware/upgrade
// - 成功路徑202 + taskID + progress 透過 WS broadcast
// - device not found404
// - chip 不支援400 / KL630 / KL730
// - service 拒絕busy409/ unsupported chip400/ brick risk500/ generic fail500
// 2. GET /api/firmware/active-tasks
// - 空清單hasActive=false、tasks=[]
// - 有 taskhasActive=true、tasks 完整欄位)
// 3. DeriveFirmwareFields
// - KDP1 legacy → IsLegacy=true / CanUpgrade=true
// - KDP2 modern → IsLegacy=false / CanUpgrade=false
// - 空 firmware → 保守不 legacy
// - chip 不支援KL630→ CanUpgrade=false 即使 legacy
// - 讀 VERSION 檔成功 / 失敗 fallback "unknown"
// 4. WebSocket broadcast schema{type:"firmware_progress", deviceId, stage, percent, ...}
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"visiona-local/server/internal/device"
"visiona-local/server/internal/driver"
"visiona-local/server/internal/firmware"
"github.com/gin-gonic/gin"
)
// ──────────────────────────────────────────────────────────────────────
// Mocks
// ──────────────────────────────────────────────────────────────────────
// fakeFirmwareService 是 firmwareService interface 的 test stub。
type fakeFirmwareService struct {
mu sync.Mutex
// programmed behaviour
upgradeErr error
upgradeTaskID string
upgradeProgress []firmware.FirmwareProgress
activeTasks []*firmware.ActiveTaskInfo
// observed
upgradeCalls []upgradeCall
cleanupCalls []string
hasActiveCalls int
}
type upgradeCall struct {
deviceID string
chip string
}
func (f *fakeFirmwareService) UpgradeFirmware(_ context.Context, deviceID, chip string) (string, <-chan firmware.FirmwareProgress, error) {
f.mu.Lock()
f.upgradeCalls = append(f.upgradeCalls, upgradeCall{deviceID: deviceID, chip: chip})
err := f.upgradeErr
taskID := f.upgradeTaskID
progress := f.upgradeProgress
f.mu.Unlock()
if err != nil {
return "", nil, err
}
ch := make(chan firmware.FirmwareProgress, len(progress)+1)
for _, ev := range progress {
ch <- ev
}
close(ch)
return taskID, ch, nil
}
func (f *fakeFirmwareService) CleanupTask(deviceID string) {
f.mu.Lock()
f.cleanupCalls = append(f.cleanupCalls, deviceID)
f.mu.Unlock()
}
func (f *fakeFirmwareService) HasActiveTask() bool {
f.mu.Lock()
defer f.mu.Unlock()
f.hasActiveCalls++
return len(f.activeTasks) > 0
}
func (f *fakeFirmwareService) GetActiveTaskInfo() []*firmware.ActiveTaskInfo {
f.mu.Lock()
defer f.mu.Unlock()
return f.activeTasks
}
// fakeDeviceLookup 模擬 deviceLookupSource。
type fakeDeviceLookup struct {
sessions map[string]*device.DeviceSession
}
func (f *fakeDeviceLookup) GetDevice(id string) (*device.DeviceSession, error) {
s, ok := f.sessions[id]
if !ok {
return nil, errors.New("device not found: " + id)
}
return s, nil
}
// fakeDriver 是 driver.DeviceDriver 的 minimal stub、只填 Info()。
type fakeDriver struct {
info driver.DeviceInfo
}
func (f *fakeDriver) Info() driver.DeviceInfo { return f.info }
func (f *fakeDriver) Connect() error { return nil }
func (f *fakeDriver) Disconnect() error { return nil }
func (f *fakeDriver) IsConnected() bool { return false }
func (f *fakeDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
func (f *fakeDriver) StartInference() error { return nil }
func (f *fakeDriver) StopInference() error { return nil }
func (f *fakeDriver) ReadInference() (*driver.InferenceResult, error) {
return nil, nil
}
func (f *fakeDriver) RunInference(_ []byte) (*driver.InferenceResult, error) {
return nil, nil
}
func (f *fakeDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
// spyBroadcasterFW 抓所有 BroadcastToRoom 呼叫、供斷言。
type spyBroadcasterFW struct {
mu sync.Mutex
calls []spyBroadcastCall
}
type spyBroadcastCall struct {
room string
data interface{}
}
func (s *spyBroadcasterFW) BroadcastToRoom(room string, data interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, spyBroadcastCall{room: room, data: data})
}
func (s *spyBroadcasterFW) snapshot() []spyBroadcastCall {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]spyBroadcastCall, len(s.calls))
copy(out, s.calls)
return out
}
// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────
func newTestFirmwareHandler(t *testing.T) (*FirmwareHandler, *fakeFirmwareService, *fakeDeviceLookup, *spyBroadcasterFW, string) {
t.Helper()
gin.SetMode(gin.TestMode)
svc := &fakeFirmwareService{}
lookup := &fakeDeviceLookup{sessions: make(map[string]*device.DeviceSession)}
hub := &spyBroadcasterFW{}
tmpDir := t.TempDir()
h := NewFirmwareHandler(svc, lookup, hub, tmpDir)
return h, svc, lookup, hub, tmpDir
}
func addFakeDevice(lookup *fakeDeviceLookup, id, devType, fwVer string) {
lookup.sessions[id] = device.NewSession(&fakeDriver{
info: driver.DeviceInfo{
ID: id,
Name: "fake-" + id,
Type: devType,
FirmwareVer: fwVer,
},
})
}
func writeVersionFile(t *testing.T, root, chip, version string) {
t.Helper()
chipDir := filepath.Join(root, chip)
if err := os.MkdirAll(chipDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", chipDir, err)
}
if err := os.WriteFile(filepath.Join(chipDir, "VERSION"), []byte(version+"\n"), 0o644); err != nil {
t.Fatalf("write VERSION: %v", err)
}
}
// performUpgradeRequest 觸發 POST /api/devices/:id/firmware/upgrade、回 ResponseRecorder。
func performUpgradeRequest(h *FirmwareHandler, deviceID string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: deviceID}}
c.Request = httptest.NewRequest(http.MethodPost, "/api/devices/"+deviceID+"/firmware/upgrade", nil)
h.UpgradeDevice(c)
return w
}
func performListActiveTasksRequest(h *FirmwareHandler) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/firmware/active-tasks", nil)
h.ListActiveTasks(c)
return w
}
// ──────────────────────────────────────────────────────────────────────
// 1. POST /api/devices/:id/firmware/upgrade
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeDevice_Success(t *testing.T) {
h, svc, lookup, hub, _ := newTestFirmwareHandler(t)
addFakeDevice(lookup, "dev-1", "kneron_kl520", "KDP")
svc.upgradeTaskID = "upgrade-dev-1-20260525"
svc.upgradeProgress = []firmware.FirmwareProgress{
{DeviceID: "dev-1", Stage: firmware.StagePreparing, Percent: 5, Direction: firmware.DirectionUpgrade},
{DeviceID: "dev-1", Stage: firmware.StageLoading, Percent: 20, Direction: firmware.DirectionUpgrade},
{DeviceID: "dev-1", Stage: firmware.StageDone, Percent: 100, AfterVersion: "v2.2.0", Direction: firmware.DirectionUpgrade},
}
w := performUpgradeRequest(h, "dev-1")
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body=%s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp.Success {
t.Errorf("success = false, want true")
}
if got := resp.Data["taskId"]; got != "upgrade-dev-1-20260525" {
t.Errorf("taskId = %v, want upgrade-dev-1-20260525", got)
}
// service 收到正確 chip
if len(svc.upgradeCalls) != 1 || svc.upgradeCalls[0].chip != firmware.ChipKL520 {
t.Errorf("svc.upgradeCalls = %+v, want chip=KL520", svc.upgradeCalls)
}
// 等 broadcast goroutine 跑完
waitForBroadcasts(t, hub, 3, 2*time.Second)
waitForCleanup(t, svc, "dev-1", 2*time.Second)
calls := hub.snapshot()
if len(calls) != 3 {
t.Fatalf("broadcast calls = %d, want 3; %+v", len(calls), calls)
}
for i, c := range calls {
if c.room != "firmware:dev-1" {
t.Errorf("call[%d].room = %q, want firmware:dev-1", i, c.room)
}
// JSON round-trip 驗證 schema
raw, _ := json.Marshal(c.data)
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Errorf("call[%d] unmarshal: %v", i, err)
continue
}
if m["type"] != "firmware_progress" {
t.Errorf("call[%d].type = %v, want firmware_progress", i, m["type"])
}
if m["deviceId"] != "dev-1" {
t.Errorf("call[%d].deviceId = %v, want dev-1", i, m["deviceId"])
}
if _, ok := m["stage"]; !ok {
t.Errorf("call[%d] missing stage", i)
}
if _, ok := m["percent"]; !ok {
t.Errorf("call[%d] missing percent", i)
}
}
// 最終 done event包含 afterVersion
doneRaw, _ := json.Marshal(calls[2].data)
var doneMsg map[string]interface{}
_ = json.Unmarshal(doneRaw, &doneMsg)
if doneMsg["afterVersion"] != "v2.2.0" {
t.Errorf("done.afterVersion = %v, want v2.2.0", doneMsg["afterVersion"])
}
}
func TestUpgradeDevice_DeviceNotFound(t *testing.T) {
h, _, _, _, _ := newTestFirmwareHandler(t)
w := performUpgradeRequest(h, "nope")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", w.Code, w.Body.String())
}
// Reviewer M9-3 第 1 輪 Minor M-4用 response JSON 結構 + 錯誤碼斷言、
// 不檢查 error message string避免 device manager error wrapping 改變
// 時 test 不必要地破裂)。
var resp struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Success {
t.Errorf("success = true, want false")
}
if resp.Error.Code != "DEVICE_NOT_FOUND" {
t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", resp.Error.Code)
}
}
func TestUpgradeDevice_UnsupportedChip(t *testing.T) {
cases := []struct {
name string
devType string
}{
{"KL630", "kneron_kl630"},
{"KL730", "kneron_kl730"},
{"unknown", "kneron_unknown"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, svc, lookup, _, _ := newTestFirmwareHandler(t)
addFakeDevice(lookup, "dev-x", tc.devType, "KDP")
w := performUpgradeRequest(h, "dev-x")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
}
// Reviewer M9-3 第 1 輪 Minor M-4透過 JSON 結構斷言錯誤碼。
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Error.Code != "FW_UNSUPPORTED_CHIP" {
t.Errorf("error.code = %q, want FW_UNSUPPORTED_CHIP", resp.Error.Code)
}
// service 不應該被呼叫
if len(svc.upgradeCalls) != 0 {
t.Errorf("svc.upgradeCalls = %d, want 0 (handler should pre-filter)", len(svc.upgradeCalls))
}
})
}
}
func TestUpgradeDevice_ServiceErrors(t *testing.T) {
cases := []struct {
name string
svcErr error
wantStatus int
wantCode string
}{
{"busy", firmware.ErrDeviceBusy, http.StatusConflict, "FW_DEVICE_BUSY"},
{"unsupported_at_service", firmware.ErrUnsupportedChip, http.StatusBadRequest, "FW_UNSUPPORTED_CHIP"},
{"brick_risk", firmware.ErrUpgradeBrickRisk, http.StatusInternalServerError, "FW_UPGRADE_BRICK_RISK"},
{"generic_fail", firmware.ErrUpgradeFailed, http.StatusInternalServerError, "FW_UPGRADE_FAILED"},
{"unknown", errors.New("boom"), http.StatusInternalServerError, "FW_UPGRADE_FAILED"},
{"not_found_at_service", firmware.ErrDeviceNotFound, http.StatusNotFound, "DEVICE_NOT_FOUND"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h, svc, lookup, _, _ := newTestFirmwareHandler(t)
addFakeDevice(lookup, "dev-1", "kneron_kl520", "KDP")
svc.upgradeErr = tc.svcErr
w := performUpgradeRequest(h, "dev-1")
if w.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.wantStatus, w.Body.String())
}
// Reviewer M9-3 第 1 輪 Minor M-4JSON 結構斷言錯誤碼。
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.Error.Code != tc.wantCode {
t.Errorf("error.code = %q, want %q", resp.Error.Code, tc.wantCode)
}
})
}
}
// ──────────────────────────────────────────────────────────────────────
// 2. GET /api/firmware/active-tasks
// ──────────────────────────────────────────────────────────────────────
func TestListActiveTasks_Empty(t *testing.T) {
h, _, _, _, _ := newTestFirmwareHandler(t)
w := performListActiveTasksRequest(h)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
HasActive bool `json:"hasActive"`
Tasks []firmware.ActiveTaskInfo `json:"tasks"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp.Success {
t.Errorf("success = false")
}
if resp.Data.HasActive {
t.Errorf("hasActive = true, want false")
}
if len(resp.Data.Tasks) != 0 {
t.Errorf("tasks = %d, want 0", len(resp.Data.Tasks))
}
// tasks 不應為 null前端較難處理
if !strings.Contains(w.Body.String(), `"tasks":[]`) {
t.Errorf("tasks 應為 [] 而非 null: %s", w.Body.String())
}
}
func TestListActiveTasks_WithTasks(t *testing.T) {
h, svc, _, _, _ := newTestFirmwareHandler(t)
now := time.Now()
svc.activeTasks = []*firmware.ActiveTaskInfo{
{
TaskID: "upgrade-dev-1",
DeviceID: "dev-1",
DeviceName: "KL520 #1",
Chip: firmware.ChipKL520,
Direction: firmware.DirectionUpgrade,
Stage: firmware.StageFlashing,
StartTs: now,
ElapsedMs: 5000,
EtaSeconds: 30,
},
}
w := performListActiveTasksRequest(h)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
}
var resp struct {
Success bool `json:"success"`
Data struct {
HasActive bool `json:"hasActive"`
Tasks []firmware.ActiveTaskInfo `json:"tasks"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp.Data.HasActive {
t.Errorf("hasActive = false, want true")
}
if len(resp.Data.Tasks) != 1 {
t.Fatalf("tasks = %d, want 1", len(resp.Data.Tasks))
}
got := resp.Data.Tasks[0]
if got.DeviceID != "dev-1" {
t.Errorf("task.deviceId = %q, want dev-1", got.DeviceID)
}
if got.Stage != firmware.StageFlashing {
t.Errorf("task.stage = %q, want flashing", got.Stage)
}
if got.EtaSeconds != 30 {
t.Errorf("task.etaSeconds = %d, want 30", got.EtaSeconds)
}
}
// ──────────────────────────────────────────────────────────────────────
// 3. DeriveFirmwareFields
// ──────────────────────────────────────────────────────────────────────
func TestDeriveFirmwareFields(t *testing.T) {
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
writeVersionFile(t, tmpDir, "KL720", "v2.2.0")
// 注意:不寫 KL630 VERSION → 測試 fallback
cases := []struct {
name string
devType string
fwVer string
wantLegacy bool
wantCanUpgrade bool
wantBundled string
}{
{
name: "KL520 KDP1 legacy → can upgrade",
devType: "kneron_kl520",
fwVer: "KDP",
wantLegacy: true,
wantCanUpgrade: true,
wantBundled: "v2.2.0",
},
{
name: "KL520 KDP1.0 legacy 字串變體 → can upgrade",
devType: "kneron_kl520",
fwVer: "KDP1.0",
wantLegacy: true,
wantCanUpgrade: true,
wantBundled: "v2.2.0",
},
{
name: "KL520 KDP2 modern → cannot upgrade",
devType: "kneron_kl520",
fwVer: "KDP2-v2.2.0",
wantLegacy: false,
wantCanUpgrade: false,
wantBundled: "v2.2.0",
},
{
name: "KL520 已是 v2.2.0 → cannot upgrade",
devType: "kneron_kl520",
fwVer: "v2.2.0",
wantLegacy: false,
wantCanUpgrade: false,
wantBundled: "v2.2.0",
},
{
name: "KL520 firmware 空字串 → 保守不 legacy",
devType: "kneron_kl520",
fwVer: "",
wantLegacy: false,
wantCanUpgrade: false,
wantBundled: "v2.2.0",
},
{
name: "KL720 KDP1 legacy → can upgrade",
devType: "kneron_kl720",
fwVer: "KDP",
wantLegacy: true,
wantCanUpgrade: true,
wantBundled: "v2.2.0",
},
{
name: "KL630 即使 KDP1 也 cannot upgradeA 階段不支援)",
devType: "kneron_kl630",
fwVer: "KDP",
wantLegacy: true,
wantCanUpgrade: false,
wantBundled: "unknown", // 無 VERSION 檔
},
{
name: "未知 chip type → 保守不可升",
devType: "kneron_unknown",
fwVer: "KDP",
wantLegacy: true,
wantCanUpgrade: false,
wantBundled: "unknown",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := h.DeriveFirmwareFields(tc.devType, tc.fwVer)
// Reviewer M9-3 第 1 輪 Major-1FirmwareDerivedFields 不再含
// firmwareVer 鍵frontend 直接讀 DeviceInfo.firmwareVersion。
// 此處只斷言 3 個衍生欄位。
if got.FirmwareIsLegacy != tc.wantLegacy {
t.Errorf("IsLegacy = %v, want %v", got.FirmwareIsLegacy, tc.wantLegacy)
}
if got.FirmwareCanUpgrade != tc.wantCanUpgrade {
t.Errorf("CanUpgrade = %v, want %v", got.FirmwareCanUpgrade, tc.wantCanUpgrade)
}
if got.BundledFirmwareVersion != tc.wantBundled {
t.Errorf("BundledFirmwareVersion = %q, want %q", got.BundledFirmwareVersion, tc.wantBundled)
}
})
}
}
func TestDeriveFirmwareFields_EmptyFirmwareDir(t *testing.T) {
// firmwareDir = "" → bundled 永遠回 "unknown"
gin.SetMode(gin.TestMode)
svc := &fakeFirmwareService{}
lookup := &fakeDeviceLookup{sessions: make(map[string]*device.DeviceSession)}
hub := &spyBroadcasterFW{}
h := NewFirmwareHandler(svc, lookup, hub, "")
got := h.DeriveFirmwareFields("kneron_kl520", "KDP")
if got.BundledFirmwareVersion != "unknown" {
t.Errorf("BundledFirmwareVersion = %q, want unknown when firmwareDir is empty", got.BundledFirmwareVersion)
}
if !got.FirmwareIsLegacy {
t.Errorf("IsLegacy = false, want true (KDP)")
}
if !got.FirmwareCanUpgrade {
t.Errorf("CanUpgrade = false, want true (chip+legacy)")
}
// Major-1FirmwareDerivedFields JSON 不應含 firmwareVer 鍵
raw, _ := json.Marshal(got)
if strings.Contains(string(raw), `"firmwareVer"`) {
t.Errorf("FirmwareDerivedFields JSON 不應含 firmwareVer 鍵got: %s", string(raw))
}
}
// TestDeriveFirmwareFields_NoFirmwareVerKey 驗證 Reviewer M9-3 第 1 輪
// Major-1 修正FirmwareDerivedFields JSON 不再含 firmwareVer 鍵(避免與
// driver.DeviceInfo 的 firmwareVersion 鍵衝突)。
func TestDeriveFirmwareFields_NoFirmwareVerKey(t *testing.T) {
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
got := h.DeriveFirmwareFields("kneron_kl520", "KDP")
raw, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
// 不應該再含 firmwareVer 鍵
if strings.Contains(body, `"firmwareVer"`) {
t.Errorf("FirmwareDerivedFields JSON 不應含 firmwareVer 鍵(避免與 DeviceInfo.firmwareVersion 衝突got: %s", body)
}
// 但應含 3 個衍生欄位
for _, key := range []string{`"firmwareIsLegacy"`, `"firmwareCanUpgrade"`, `"bundledFirmwareVersion"`} {
if !strings.Contains(body, key) {
t.Errorf("FirmwareDerivedFields JSON 缺少 %s; got: %s", key, body)
}
}
}
// TestEnrichDevicesJSONOutput 驗證 device list endpoint 的 JSON schema
// 用 enrichDevices 模擬 ListDevices / ScanDevices 的內部組裝(不啟整個 gin
// routergrep response JSON 確認:
// - 有 "firmwareVersion" 鍵DeviceInfo embedded、frontend SoT
// - 沒 "firmwareVer" 鍵(避免雙鍵衝突)
// - 有 firmwareIsLegacy / firmwareCanUpgrade / bundledFirmwareVersion 衍生欄位
//
// Reviewer M9-3 第 1 輪 Major-1必須補這個 schema 測試。
func TestEnrichDevicesJSONOutput(t *testing.T) {
fwh, _, _, _, tmpDir := newTestFirmwareHandler(t)
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
dh := NewDeviceHandler(nil, nil, nil, nil)
dh.SetFirmwareHandler(fwh)
devices := []driver.DeviceInfo{
{
ID: "dev-1",
Name: "KL520 #1",
Type: "kneron_kl520",
FirmwareVer: "KDP", // legacy
Status: driver.StatusDetected,
},
}
enriched := dh.enrichDevices(devices)
if len(enriched) != 1 {
t.Fatalf("enrichDevices returned %d entries, want 1", len(enriched))
}
raw, err := json.Marshal(enriched[0])
if err != nil {
t.Fatalf("marshal: %v", err)
}
body := string(raw)
// 1. 必須有 firmwareVersion 鍵(來自 DeviceInfo.FirmwareVer json tag
if !strings.Contains(body, `"firmwareVersion":"KDP"`) {
t.Errorf("缺少 firmwareVersion 鍵; got: %s", body)
}
// 2. 絕對不應有 firmwareVer 鍵(避免雙鍵衝突)
if strings.Contains(body, `"firmwareVer"`) {
t.Errorf("device JSON 不應含 firmwareVer 鍵(與 firmwareVersion 衝突); got: %s", body)
}
// 3. 衍生欄位齊全
for _, key := range []string{`"firmwareIsLegacy"`, `"firmwareCanUpgrade"`, `"bundledFirmwareVersion"`} {
if !strings.Contains(body, key) {
t.Errorf("device JSON 缺少 %s; got: %s", key, body)
}
}
// 4. 既有 DeviceInfo 欄位也應該還在(不破壞既有 frontend client
for _, key := range []string{`"id":"dev-1"`, `"name":"KL520 #1"`, `"type":"kneron_kl520"`, `"status":"detected"`} {
if !strings.Contains(body, key) {
t.Errorf("device JSON 缺少 DeviceInfo 既有欄位 %s; got: %s", key, body)
}
}
}
// TestEnrichDevicesJSONOutput_NilFwHandler 驗證 fallback 路徑fwHandler=nil
// 時、JSON 也不應含 firmwareVer 鍵、firmwareVersion 應從 DeviceInfo 帶上。
func TestEnrichDevicesJSONOutput_NilFwHandler(t *testing.T) {
dh := NewDeviceHandler(nil, nil, nil, nil)
// 刻意不 SetFirmwareHandler → fwHandler 為 nil
devices := []driver.DeviceInfo{
{
ID: "dev-1",
Type: "kneron_kl520",
FirmwareVer: "KDP",
Status: driver.StatusDetected,
},
}
enriched := dh.enrichDevices(devices)
raw, _ := json.Marshal(enriched[0])
body := string(raw)
if !strings.Contains(body, `"firmwareVersion":"KDP"`) {
t.Errorf("nil fwHandler 時 firmwareVersion 應從 DeviceInfo 帶上; got: %s", body)
}
if strings.Contains(body, `"firmwareVer"`) {
t.Errorf("nil fwHandler 時不應含 firmwareVer 鍵; got: %s", body)
}
if !strings.Contains(body, `"bundledFirmwareVersion":"unknown"`) {
t.Errorf("nil fwHandler 時 bundled 應為 unknown; got: %s", body)
}
}
// TestIsLegacyFirmware_BridgeParity 驗證 Go isLegacyFirmware 與 bridge.py
// _fw_classify_legacy 對同樣 firmware 字串給出一致結果Reviewer M9-3
// 第 1 輪 S-1跨端 drift 防護)。
//
// 真值表來源kneron_bridge.py line 1463-1508 `_fw_classify_legacy`。
// 注意bridge.py 把空字串 ""USB Boot state 不回 firmware視為 legacy、
// 但 Go list endpoint 看到空字串通常是 device 還沒 connect 過 — 保守當
// 非 legacy不顯示升級按鈕此差異已在 isLegacyFirmware godoc 註記。
func TestIsLegacyFirmware_BridgeParity(t *testing.T) {
// product_id=0x0200 那條規則 Go 端不檢查KL720 KDP legacy 由 chip
// type + upgrade 流程處理、不在 list endpoint canUpgrade 邏輯內)。
cases := []struct {
fw string
wantLegacy bool
note string
}{
// 已知 KDP1 legacy 字串
{"KDP", true, "bridge.py legacy_exact"},
{"KDP1", true, "bridge.py legacy_exact"},
{"USB BOOT", true, "bridge.py legacy_exact"},
{"USB Boot", true, "case insensitive"},
{"USB BOOT LOADER", true, "bridge.py legacy_exact"},
{"LOADER", true, "bridge.py legacy_exact"},
{"BOOTLOADER", true, "bridge.py legacy_exact"},
// KDP1.x 變體
{"KDP1.0", true, "bridge.py KDP1. prefix"},
{"KDP1.5", true, "bridge.py KDP1. prefix"},
{"KDP1 alpha", true, "bridge.py KDP1<space> prefix"},
// KDP2-KDP9 明示放行
{"KDP2", false, "bridge.py KDP2 prefix → modern"},
{"KDP2.0", false, "bridge.py KDP2 prefix"},
{"KDP2-v2.2.0", false, "bridge.py KDP2 prefix"},
{"KDP3", false, "bridge.py KDP3 prefix → forward-compat"},
{"KDP3.1", false, "bridge.py KDP3 prefix → forward-compat"},
{"KDP9", false, "bridge.py KDP9 prefix → forward-compat"},
// 未知字串 → 保守不 legacybridge.py default
{"NEF", false, "bridge.py default for unknown"},
{"K3", false, "bridge.py default for unknown"},
{"v2.2.0", false, "version string only → not legacy"},
// Go 端對空字串的特例(保守、與 bridge.py 在 list endpoint 場景一致)
{"", false, "Go list endpoint conservative (not connected yet)"},
}
for _, tc := range cases {
t.Run(tc.fw+"_"+tc.note, func(t *testing.T) {
got := isLegacyFirmware(tc.fw)
if got != tc.wantLegacy {
t.Errorf("isLegacyFirmware(%q) = %v, want %v (%s)",
tc.fw, got, tc.wantLegacy, tc.note)
}
})
}
}
// TestBundledVersionFor_CacheMissDoesNotPoisonRetry 驗證 Reviewer M9-3
// 第 1 輪 Minor M-3cache miss 後不寫入 cache、下次 list device 會重試
// 並讀到新版本情境CI first build 時 firmware 檔還沒 ready
func TestBundledVersionFor_CacheMissDoesNotPoisonRetry(t *testing.T) {
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
// 1. VERSION 檔還沒 ready → 應回 "unknown"
got1 := h.bundledVersionFor("KL520")
if got1 != "unknown" {
t.Fatalf("first call (no VERSION file) = %q, want unknown", got1)
}
// 2. 確認沒有 cache 失敗結果(否則下次重試會永遠回 unknown
h.bundledMu.RLock()
_, cached := h.bundledVersions["KL520"]
h.bundledMu.RUnlock()
if cached {
t.Errorf("失敗結果不應寫入 cacheM-3cache 含 KL520 entry")
}
// 3. 模擬 CI 後續產出 VERSION 檔
writeVersionFile(t, tmpDir, "KL520", "v2.2.0")
// 4. 第二次呼叫應該讀到實際版本(重試成功)
got2 := h.bundledVersionFor("KL520")
if got2 != "v2.2.0" {
t.Errorf("retry after VERSION ready = %q, want v2.2.0", got2)
}
// 5. 第三次應從 cache 命中success 才 cache
h.bundledMu.RLock()
cachedVal := h.bundledVersions["KL520"]
h.bundledMu.RUnlock()
if cachedVal != "v2.2.0" {
t.Errorf("success 應 cachecache value = %q", cachedVal)
}
}
// TestBundledVersionFor_EmptyFileNotCached 驗證空 VERSION 檔(不合理狀態)
// 也不應該污染 cache。
func TestBundledVersionFor_EmptyFileNotCached(t *testing.T) {
h, _, _, _, tmpDir := newTestFirmwareHandler(t)
// 寫空 VERSION 檔
chipDir := filepath.Join(tmpDir, "KL520")
if err := os.MkdirAll(chipDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(chipDir, "VERSION"), []byte(""), 0o644); err != nil {
t.Fatal(err)
}
got := h.bundledVersionFor("KL520")
if got != "unknown" {
t.Errorf("empty file = %q, want unknown", got)
}
h.bundledMu.RLock()
_, cached := h.bundledVersions["KL520"]
h.bundledMu.RUnlock()
if cached {
t.Errorf("空檔不應寫入 cache避免下次重試永遠回 unknown")
}
}
// TestForwardGoroutine_ExitsOnChannelClose 驗證 Reviewer M9-3 第 1 輪 S-5
// service 端 close progressCh 後、forward goroutine 必須退出(不能 leak
//
// 透過觀察 svc.CleanupTask 被呼叫 = forwardProgressToWS 從 for-range 退出
// 並執行了 deferred-like cleanup程式碼是在 for 結束後同步呼叫、非 defer
func TestForwardGoroutine_ExitsOnChannelClose(t *testing.T) {
h, svc, lookup, hub, _ := newTestFirmwareHandler(t)
addFakeDevice(lookup, "dev-leak", "kneron_kl520", "KDP")
svc.upgradeTaskID = "tk-leak"
svc.upgradeProgress = []firmware.FirmwareProgress{
{DeviceID: "dev-leak", Stage: firmware.StagePreparing, Percent: 1},
{DeviceID: "dev-leak", Stage: firmware.StageDone, Percent: 100},
}
w := performUpgradeRequest(h, "dev-leak")
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202", w.Code)
}
// 等 broadcast + cleanup 都完成 = goroutine 已退出
waitForBroadcasts(t, hub, 2, 2*time.Second)
waitForCleanup(t, svc, "dev-leak", 2*time.Second)
// 直接斷言cleanupCalls 含 dev-leak 且只一次goroutine 退出後不會再
// 重複呼)
svc.mu.Lock()
count := 0
for _, id := range svc.cleanupCalls {
if id == "dev-leak" {
count++
}
}
svc.mu.Unlock()
if count != 1 {
t.Errorf("CleanupTask(dev-leak) 呼叫 %d 次want 1goroutine 該乾淨退出一次)", count)
}
}
func TestChipFromDeviceType(t *testing.T) {
cases := []struct {
devType string
want string
}{
{"kneron_kl520", "KL520"},
{"kneron_kl720", "KL720"},
{"kneron_kl630", "KL630"},
{"kneron_kl730", "KL730"},
{"Kneron_KL520", "KL520"}, // case insensitive
{"unknown", ""},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.devType, func(t *testing.T) {
got := ChipFromDeviceType(tc.devType)
if got != tc.want {
t.Errorf("ChipFromDeviceType(%q) = %q, want %q", tc.devType, got, tc.want)
}
})
}
}
// ──────────────────────────────────────────────────────────────────────
// helpers等 goroutine 完成
// ──────────────────────────────────────────────────────────────────────
func waitForBroadcasts(t *testing.T, hub *spyBroadcasterFW, n int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if len(hub.snapshot()) >= n {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timeout waiting for %d broadcasts, got %d", n, len(hub.snapshot()))
}
func waitForCleanup(t *testing.T, svc *fakeFirmwareService, deviceID string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
svc.mu.Lock()
for _, id := range svc.cleanupCalls {
if id == deviceID {
svc.mu.Unlock()
return
}
}
svc.mu.Unlock()
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timeout waiting for CleanupTask(%q)", deviceID)
}