jim800121chen c03eb6fd0e feat(local-tool): M9-2 — Go driver UpgradeFirmware + firmware service module
A 階段第二個 milestone、銜接 M9-1 bridge.py、暴露 service layer 給 M9-3 API/WebSocket。

New module `server/internal/firmware/`:
- types.go: 123 行(FirmwareVersion / FirmwareProgress / ActiveTaskInfo / UpgradeDriver interface / 8 reason const)
- progress.go: 147 行(仿 flash pattern 的 Tracker、Task.cancel 預留 SIGTERM force-cancel godoc)
- service.go: 373 行(核心 service:UpgradeFirmware / HasActiveTask / GetActiveTaskInfo / RequestShutdown / WaitForActiveTasks / ListBundledVersions / GetCurrentVersion)
- service_test.go: 676 行、13 個 test 含 MultiDeviceParallel

Driver layer:
- kl720_driver.go: 697 → 1054 行(+357、新 UpgradeFirmware method + tryRouteFirmwareEvent + sendCommandForUpgrade snapshot pattern)
- kl720_driver_test.go: 360 行、11 個 test(含 InfoNotBlockedDuringUpgrade / CtxCancelReleasesBridge / StderrEventAfterCtxCancel 100 round stress)

關鍵設計:
- flash 與 firmware 模組分離(不 import flash)
- UpgradeDriver interface 隔離 driver 細節、DeviceLookup interface 隔離 device manager
- 中介 channel pattern(service ↔ driver)方便 service 補欄位(DeviceID / Direction / BeforeVersion)
- timeout 雙保險:chip timeout + 30s margin
- 8 reason enum 對齊 bridge.py、stage 採 Design 命名

Concurrency race 修復(M9-2 Reviewer round 1 → round 2):
- Major 1(mutex deadlock):新 fwUpgradeMu 獨立鎖 + sendCommandForUpgrade snapshot stdin/stdout pattern、避開 d.mu field-level race + 升級期間 Info/Disconnect 不被卡 + timeout 路徑無死鎖
- Major 2(close-channel race):tryRouteFirmwareEvent 持 fwMu 整段、配合 defer setFirmwareProgressCh(nil) 提供 happen-before、絕無 send on closed channel panic

Reviewer 兩輪審查:
- Round 1: 0 Critical / 2 Major / 5 Minor / 5 Suggestion
- Round 2: 0 Critical / 0 Major / 2 Minor / 2 Suggestion(11/12 issue 修到位、Suggestion 4 留 follow-up)

M9-1 follow-up 順手清:
- m5(test 死碼 _firmware_upgrade_start_ts 殘留兩行)已清
- s5(test 註解 idempotent shape 說明)已加

測試:
- go test ./... -race -count=1: 全綠(28s、無 regression)
- Python: 36 tests + 22 subtests 全綠(0.31s)
- go vet / build: 0 output

下一步:M9-3 API handler + WebSocket progress(CI 建議 `go test -race -count=3` 提升 race 偵測強度)

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

677 lines
24 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 firmware
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"time"
"visiona-local/server/internal/driver"
)
// ──────────────────────────────────────────────────────────────────────
// Mocks
// ──────────────────────────────────────────────────────────────────────
// fakeDriver 是 UpgradeDriver 的測試實作、可程式化 progress 序列與終態 error。
type fakeDriver struct {
info driver.DeviceInfo
// scripted progress eventsrunUpgrade 收到 ctx.Done 之前會依序 push
events []FirmwareProgress
// 每個 event 之間的 delay
delay time.Duration
// 最終回傳 error
finalErr error
// 觀察:實際被呼叫的 chip / call count
mu sync.Mutex
calledChip string
callCount int
gotCtxDone bool
}
func (f *fakeDriver) Info() driver.DeviceInfo { return f.info }
func (f *fakeDriver) UpgradeFirmware(ctx context.Context, chip string, progressCh chan<- FirmwareProgress) error {
f.mu.Lock()
f.calledChip = chip
f.callCount++
f.mu.Unlock()
for _, ev := range f.events {
select {
case <-ctx.Done():
f.mu.Lock()
f.gotCtxDone = true
f.mu.Unlock()
return ctx.Err()
case <-time.After(f.delay):
}
select {
case progressCh <- ev:
case <-ctx.Done():
f.mu.Lock()
f.gotCtxDone = true
f.mu.Unlock()
return ctx.Err()
}
}
return f.finalErr
}
// fakeLookup 將 deviceID 映射到 fakeDriver。
type fakeLookup struct {
drivers map[string]UpgradeDriver
}
func (l *fakeLookup) GetUpgradeDriver(deviceID string) (UpgradeDriver, error) {
d, ok := l.drivers[deviceID]
if !ok {
return nil, errors.New("no such device")
}
return d, nil
}
func newServiceWith(drv UpgradeDriver, deviceID string) *Service {
return NewService(
&fakeLookup{drivers: map[string]UpgradeDriver{deviceID: drv}},
FirmwareDir{Root: "/tmp/test-firmware"},
)
}
// ──────────────────────────────────────────────────────────────────────
// 1. 5-stage 成功路徑events 都被 forward、driver 端 chip 收到、終態 done
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_SuccessFiveStages(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-1", Name: "KL520-A", FirmwareVer: "KDP"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5, Message: "scan"},
{Stage: StageLoading, Percent: 20, Message: "loader"},
{Stage: StageFlashing, Percent: 50, Message: "kdp2"},
{Stage: StageVerifying, Percent: 90, Message: "verify"},
{Stage: StageDone, Percent: 100, AfterVersion: "2.2.0"},
},
delay: 1 * time.Millisecond,
}
svc := newServiceWith(drv, "dev-1")
taskID, ch, err := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if err != nil {
t.Fatalf("UpgradeFirmware error: %v", err)
}
if taskID == "" {
t.Fatalf("expected non-empty taskID")
}
got := drainProgress(t, ch, 5*time.Second)
if len(got) != 5 {
t.Fatalf("expected 5 events, got %d: %+v", len(got), got)
}
wantStages := []string{StagePreparing, StageLoading, StageFlashing, StageVerifying, StageDone}
for i, ev := range got {
if ev.Stage != wantStages[i] {
t.Errorf("ev[%d].Stage = %q, want %q", i, ev.Stage, wantStages[i])
}
if ev.DeviceID != "dev-1" {
t.Errorf("ev[%d].DeviceID = %q, want dev-1", i, ev.DeviceID)
}
if ev.Direction != DirectionUpgrade {
t.Errorf("ev[%d].Direction = %q, want upgrade", i, ev.Direction)
}
if ev.BeforeVersion != "KDP" {
t.Errorf("ev[%d].BeforeVersion = %q, want KDP", i, ev.BeforeVersion)
}
}
// driver 收到 chip
if drv.calledChip != ChipKL520 {
t.Errorf("driver chip = %q, want KL520", drv.calledChip)
}
if drv.callCount != 1 {
t.Errorf("driver callCount = %d, want 1", drv.callCount)
}
// task 已 donetracker 不再 ActiveTasks 列出)
svc.WaitForActiveTasks(2 * time.Second)
if svc.HasActiveTask() {
t.Errorf("HasActiveTask still true after done")
}
}
// ──────────────────────────────────────────────────────────────────────
// 2. 失敗路徑:各種 reason 都正確 forward
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_FailureReasons(t *testing.T) {
failureCases := []struct {
name string
reason string
stage string
}{
{"scan_not_found", ReasonScanNotFound, StagePreparing},
{"connect_failed", ReasonConnectFailed, StagePreparing},
{"loader_write_failed", ReasonLoaderWriteFailed, StageLoading},
{"upgrade_mid_failed", ReasonUpgradeMidFailed, StageFlashing},
{"verify_mismatch", ReasonVerifyMismatch, StageVerifying},
{"verify_not_found", ReasonVerifyNotFound, StageVerifying},
{"disconnect_during_op", ReasonDisconnectDuringOp, StageFlashing},
}
for _, tc := range failureCases {
t.Run(tc.name, func(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-x", FirmwareVer: "KDP"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{
Stage: StageError,
Percent: -1,
Error: "simulated " + tc.reason,
Reason: tc.reason,
RawError: "raw " + tc.reason,
},
},
finalErr: errors.New("bridge failed: " + tc.reason),
delay: 1 * time.Millisecond,
}
svc := newServiceWith(drv, "dev-x")
_, ch, err := svc.UpgradeFirmware(context.Background(), "dev-x", ChipKL520)
if err != nil {
t.Fatalf("UpgradeFirmware setup error: %v", err)
}
got := drainProgress(t, ch, 5*time.Second)
// 找到 error event
var errEv *FirmwareProgress
for i := range got {
if got[i].Stage == StageError {
errEv = &got[i]
break
}
}
if errEv == nil {
t.Fatalf("no error event in %+v", got)
}
if errEv.Reason != tc.reason {
t.Errorf("Reason = %q, want %q", errEv.Reason, tc.reason)
}
if errEv.Percent != -1 {
t.Errorf("Percent = %d, want -1", errEv.Percent)
}
if errEv.DeviceID != "dev-x" {
t.Errorf("DeviceID 應被補上、got %q", errEv.DeviceID)
}
})
}
}
// ──────────────────────────────────────────────────────────────────────
// 3. Timeoutservice 端外層 context 超時、driver 收到 ctx.Done
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_ContextTimeout(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-slow"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{Stage: StageLoading, Percent: 20},
},
delay: 200 * time.Millisecond, // 每個 event 200ms2 個 events ≈ 400ms
}
svc := newServiceWith(drv, "dev-slow")
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, ch, err := svc.UpgradeFirmware(ctx, "dev-slow", ChipKL520)
if err != nil {
t.Fatalf("UpgradeFirmware setup error: %v", err)
}
got := drainProgress(t, ch, 3*time.Second)
// 應該有一個 timeout error event
var sawTimeout bool
for _, ev := range got {
if ev.Stage == StageError && ev.Reason == ReasonTimeout {
sawTimeout = true
break
}
}
if !sawTimeout {
t.Errorf("expected timeout error event in %+v", got)
}
// driver 應該觀察到 ctx.Done
drv.mu.Lock()
defer drv.mu.Unlock()
if !drv.gotCtxDone {
t.Errorf("driver should have observed ctx.Done")
}
}
// ──────────────────────────────────────────────────────────────────────
// 4. Mutex同 device 同時兩個 upgrade → 第二個拒絕FW_DEVICE_BUSY
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_DeviceBusy(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-1"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{Stage: StageDone, Percent: 100, AfterVersion: "2.2.0"},
},
delay: 100 * time.Millisecond, // 給時間讓第一個還沒做完時第二個就來
}
svc := newServiceWith(drv, "dev-1")
// 第一個 task
_, ch1, err := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if err != nil {
t.Fatalf("first UpgradeFirmware error: %v", err)
}
// 第二個 task 應該被拒絕device busy
_, _, err2 := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if err2 == nil {
t.Fatalf("second UpgradeFirmware should fail with ErrDeviceBusy")
}
if !errors.Is(err2, ErrDeviceBusy) {
t.Errorf("second error = %v, want ErrDeviceBusy", err2)
}
// 等第一個 task 結束、確認 channel 收到 done
got := drainProgress(t, ch1, 5*time.Second)
if len(got) == 0 {
t.Fatalf("first task got no events")
}
if got[len(got)-1].Stage != StageDone {
t.Errorf("last event stage = %q, want done", got[len(got)-1].Stage)
}
// 第一個結束後、可以再 start 一個(同 device
svc.WaitForActiveTasks(2 * time.Second)
svc.CleanupTask("dev-1")
_, _, err3 := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if err3 != nil {
t.Errorf("third UpgradeFirmware after cleanup should succeed, got: %v", err3)
}
}
// ──────────────────────────────────────────────────────────────────────
// 5. HasActiveTask / GetActiveTaskInfo
// ──────────────────────────────────────────────────────────────────────
func TestHasActiveTask_AndActiveTaskInfo(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-1", Name: "KL720-Z", FirmwareVer: "KDP"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{Stage: StageLoading, Percent: 20},
{Stage: StageDone, Percent: 100, AfterVersion: "2.2.0"},
},
delay: 80 * time.Millisecond,
}
svc := newServiceWith(drv, "dev-1")
if svc.HasActiveTask() {
t.Fatalf("HasActiveTask should be false initially")
}
if len(svc.GetActiveTaskInfo()) != 0 {
t.Fatalf("GetActiveTaskInfo should be empty initially")
}
_, ch, err := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL720)
if err != nil {
t.Fatalf("UpgradeFirmware error: %v", err)
}
// 等 progress 推一個出來、確保 task 已在 active 狀態
select {
case <-ch:
case <-time.After(2 * time.Second):
t.Fatalf("no progress received")
}
if !svc.HasActiveTask() {
t.Errorf("HasActiveTask should be true while upgrading")
}
infos := svc.GetActiveTaskInfo()
if len(infos) != 1 {
t.Fatalf("ActiveTaskInfo len = %d, want 1", len(infos))
}
info := infos[0]
if info.DeviceID != "dev-1" {
t.Errorf("DeviceID = %q, want dev-1", info.DeviceID)
}
if info.DeviceName != "KL720-Z" {
t.Errorf("DeviceName = %q, want KL720-Z", info.DeviceName)
}
if info.Chip != ChipKL720 {
t.Errorf("Chip = %q, want KL720", info.Chip)
}
if info.Direction != DirectionUpgrade {
t.Errorf("Direction = %q, want upgrade", info.Direction)
}
if info.EtaSeconds <= 0 || info.EtaSeconds > 200 {
t.Errorf("EtaSeconds = %d, want 0 < n <= 200", info.EtaSeconds)
}
// drain rest
drainProgress(t, ch, 5*time.Second)
svc.WaitForActiveTasks(2 * time.Second)
if svc.HasActiveTask() {
t.Errorf("HasActiveTask should be false after task done")
}
}
// ──────────────────────────────────────────────────────────────────────
// 6. UnsupportedChipKL630 / KL730 / 亂值 → ErrUnsupportedChip
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_UnsupportedChip(t *testing.T) {
drv := &fakeDriver{info: driver.DeviceInfo{ID: "dev-1"}}
svc := newServiceWith(drv, "dev-1")
for _, chip := range []string{"KL630", "KL730", "", "kl520", "garbage"} {
_, _, err := svc.UpgradeFirmware(context.Background(), "dev-1", chip)
if !errors.Is(err, ErrUnsupportedChip) {
t.Errorf("chip=%q: err=%v, want ErrUnsupportedChip", chip, err)
}
}
}
// ──────────────────────────────────────────────────────────────────────
// 7. DeviceNotFound
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_DeviceNotFound(t *testing.T) {
svc := NewService(&fakeLookup{drivers: map[string]UpgradeDriver{}}, FirmwareDir{Root: "/tmp"})
_, _, err := svc.UpgradeFirmware(context.Background(), "ghost", ChipKL520)
if !errors.Is(err, ErrDeviceNotFound) {
t.Errorf("err=%v, want ErrDeviceNotFound", err)
}
}
// ──────────────────────────────────────────────────────────────────────
// 8. RequestShutdownshutdown 後新 task 被拒
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_ShutdownRejection(t *testing.T) {
drv := &fakeDriver{info: driver.DeviceInfo{ID: "dev-1"}}
svc := newServiceWith(drv, "dev-1")
svc.RequestShutdown()
_, _, err := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if !errors.Is(err, ErrDeviceBusy) {
t.Errorf("err=%v, want ErrDeviceBusy after shutdown", err)
}
}
// ──────────────────────────────────────────────────────────────────────
// 9. WaitForActiveTasks有 active 時 wait 會回 falsetimeout
// ──────────────────────────────────────────────────────────────────────
func TestWaitForActiveTasks_TimeoutWhenBusy(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-1"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{Stage: StageDone, Percent: 100},
},
delay: 300 * time.Millisecond,
}
svc := newServiceWith(drv, "dev-1")
_, ch, err := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if err != nil {
t.Fatalf("UpgradeFirmware error: %v", err)
}
clean := svc.WaitForActiveTasks(50 * time.Millisecond)
if clean {
t.Errorf("expected timeout (false), got clean (true)")
}
// drain rest 確保不 leak
drainProgress(t, ch, 5*time.Second)
}
// ──────────────────────────────────────────────────────────────────────
// 10. ListBundledVersions / GetCurrentVersion 基本行為
// ──────────────────────────────────────────────────────────────────────
func TestListBundledVersions(t *testing.T) {
// 建 temp firmware 結構Suggestion 3 後、ListBundledVersions 會 os.Stat chipDir
tmpRoot := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmpRoot, ChipKL520), 0o755); err != nil {
t.Fatalf("setup mkdir: %v", err)
}
svc := NewService(&fakeLookup{drivers: map[string]UpgradeDriver{}}, FirmwareDir{Root: tmpRoot})
versions, err := svc.ListBundledVersions(ChipKL520)
if err != nil {
t.Fatalf("ListBundledVersions error: %v", err)
}
if len(versions) != 1 {
t.Fatalf("versions len = %d, want 1 (A 階段扁平)", len(versions))
}
if !versions[0].IsCurrent || !versions[0].IsBundled {
t.Errorf("expected IsCurrent + IsBundled, got %+v", versions[0])
}
// 不支援 chip
_, err = svc.ListBundledVersions("KL630")
if !errors.Is(err, ErrUnsupportedChip) {
t.Errorf("KL630 error = %v, want ErrUnsupportedChip", err)
}
// 支援的 chip 但 firmware 漏 buildSuggestion 3 行為)
_, err = svc.ListBundledVersions(ChipKL720)
if err == nil {
t.Errorf("expected error when chip dir missing")
}
// 沒設 fwDir
svc2 := NewService(&fakeLookup{}, FirmwareDir{})
_, err = svc2.ListBundledVersions(ChipKL520)
if err == nil {
t.Errorf("expected error when fwDir.Root is empty")
}
}
func TestGetCurrentVersion(t *testing.T) {
drv := &fakeDriver{info: driver.DeviceInfo{ID: "dev-1", FirmwareVer: "v2.2.0"}}
svc := newServiceWith(drv, "dev-1")
ver, err := svc.GetCurrentVersion("dev-1")
if err != nil {
t.Fatalf("GetCurrentVersion error: %v", err)
}
if ver.Version != "v2.2.0" {
t.Errorf("Version = %q, want v2.2.0", ver.Version)
}
// not found
_, err = svc.GetCurrentVersion("ghost")
if !errors.Is(err, ErrDeviceNotFound) {
t.Errorf("err = %v, want ErrDeviceNotFound", err)
}
}
// ──────────────────────────────────────────────────────────────────────
// 11. Multi-device 平行 upgradeMinor 2 + Suggestion 2
//
// 3 個 device 同時升級、確認:
// - tracker key 是 deviceID、不同 device 互不影響(不誤匹配)
// - 每個 task 的 ProgressCh 收到自己的 events、DeviceID 對得到
// - 3 個 task 都能完成、HasActiveTask 在最後回 false
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_MultiDeviceParallel(t *testing.T) {
mkDrv := func(id string) *fakeDriver {
return &fakeDriver{
info: driver.DeviceInfo{ID: id, Name: id + "-name", FirmwareVer: "KDP"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{Stage: StageFlashing, Percent: 50},
{Stage: StageDone, Percent: 100, AfterVersion: "2.2.0"},
},
delay: 20 * time.Millisecond,
}
}
drvA := mkDrv("dev-A")
drvB := mkDrv("dev-B")
drvC := mkDrv("dev-C")
svc := NewService(
&fakeLookup{drivers: map[string]UpgradeDriver{
"dev-A": drvA,
"dev-B": drvB,
"dev-C": drvC,
}},
FirmwareDir{Root: "/tmp"},
)
type startResult struct {
id string
taskID string
ch <-chan FirmwareProgress
err error
}
startCh := make(chan startResult, 3)
for _, id := range []string{"dev-A", "dev-B", "dev-C"} {
go func(deviceID string) {
tid, ch, err := svc.UpgradeFirmware(context.Background(), deviceID, ChipKL520)
startCh <- startResult{id: deviceID, taskID: tid, ch: ch, err: err}
}(id)
}
// 收集 3 個 start 結果、組成 deviceID → progressCh map
results := make(map[string]startResult, 3)
for i := 0; i < 3; i++ {
select {
case r := <-startCh:
if r.err != nil {
t.Fatalf("%s start err: %v", r.id, r.err)
}
results[r.id] = r
case <-time.After(3 * time.Second):
t.Fatalf("timeout waiting for 3 starts; got %d", i)
}
}
// 各自 drain、驗證 events 的 DeviceID 對得到、且收到 done
var wg sync.WaitGroup
for id, r := range results {
wg.Add(1)
go func(id string, ch <-chan FirmwareProgress) {
defer wg.Done()
evs := drainProgress(t, ch, 5*time.Second)
if len(evs) == 0 {
t.Errorf("%s: no events", id)
return
}
// 每個 event 的 DeviceID 必須 = id不能誤匹配到別的 device
for i, ev := range evs {
if ev.DeviceID != id {
t.Errorf("%s ev[%d].DeviceID = %q, want %q", id, i, ev.DeviceID, id)
}
}
// 最後 event 必為 done
if last := evs[len(evs)-1]; last.Stage != StageDone {
t.Errorf("%s last stage = %q, want done", id, last.Stage)
}
}(id, r.ch)
}
wg.Wait()
// 全部 done 後 HasActiveTask 必為 false
svc.WaitForActiveTasks(3 * time.Second)
if svc.HasActiveTask() {
t.Errorf("HasActiveTask still true after all 3 tasks done")
}
// 每個 driver 都剛好被呼叫一次(沒誤匹配到別的 deviceID
for _, d := range []*fakeDriver{drvA, drvB, drvC} {
d.mu.Lock()
if d.callCount != 1 {
t.Errorf("driver(%s) callCount = %d, want 1", d.info.ID, d.callCount)
}
if d.calledChip != ChipKL520 {
t.Errorf("driver(%s) chip = %q, want KL520", d.info.ID, d.calledChip)
}
d.mu.Unlock()
}
}
// ──────────────────────────────────────────────────────────────────────
// 12. Suggestion 1StageDone 去重
//
// driver 端送雙保險 donestderr push + sendCommand 補一發、service 端
// forward 應只 forward 一次 done 給 caller。
// ──────────────────────────────────────────────────────────────────────
func TestUpgradeFirmware_DedupeDoneEvent(t *testing.T) {
drv := &fakeDriver{
info: driver.DeviceInfo{ID: "dev-1", FirmwareVer: "KDP"},
events: []FirmwareProgress{
{Stage: StagePreparing, Percent: 5},
{Stage: StageDone, Percent: 100, AfterVersion: "2.2.0"},
// 第二次 done模擬 stderr + sendCommand 雙保險)
{Stage: StageDone, Percent: 100, AfterVersion: "2.2.0"},
},
delay: 1 * time.Millisecond,
}
svc := newServiceWith(drv, "dev-1")
_, ch, err := svc.UpgradeFirmware(context.Background(), "dev-1", ChipKL520)
if err != nil {
t.Fatalf("UpgradeFirmware error: %v", err)
}
got := drainProgress(t, ch, 5*time.Second)
// 應該只看到一個 done去重後
doneCount := 0
for _, ev := range got {
if ev.Stage == StageDone {
doneCount++
}
}
if doneCount != 1 {
t.Errorf("doneCount = %d, want 1 (deduped); events: %+v", doneCount, got)
}
}
// ──────────────────────────────────────────────────────────────────────
// 13. drainProgress helper
// ──────────────────────────────────────────────────────────────────────
func drainProgress(t *testing.T, ch <-chan FirmwareProgress, maxWait time.Duration) []FirmwareProgress {
t.Helper()
var out []FirmwareProgress
timer := time.NewTimer(maxWait)
defer timer.Stop()
for {
select {
case ev, ok := <-ch:
if !ok {
return out
}
out = append(out, ev)
case <-timer.C:
t.Fatalf("drainProgress timeout after %v, got %d events: %+v", maxWait, len(out), out)
return out
}
}
}