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>
939 lines
32 KiB
Go
939 lines
32 KiB
Go
package handlers
|
||
|
||
// firmware_handler_test.go — M9-3 unit test
|
||
//
|
||
// 覆蓋:
|
||
// 1. POST /api/devices/:id/firmware/upgrade
|
||
// - 成功路徑(202 + taskID + progress 透過 WS broadcast)
|
||
// - device not found(404)
|
||
// - chip 不支援(400 / KL630 / KL730)
|
||
// - service 拒絕:busy(409)/ unsupported chip(400)/ brick risk(500)/ generic fail(500)
|
||
// 2. GET /api/firmware/active-tasks
|
||
// - 空清單(hasActive=false、tasks=[])
|
||
// - 有 task(hasActive=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-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 != 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 upgrade(A 階段不支援)",
|
||
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-1:FirmwareDerivedFields 不再含
|
||
// 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-1:FirmwareDerivedFields 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
|
||
// router),grep 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"},
|
||
// 未知字串 → 保守不 legacy(bridge.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-3:cache 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("失敗結果不應寫入 cache(M-3);cache 含 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 應 cache;cache 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 1(goroutine 該乾淨退出一次)", 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)
|
||
}
|