package flash import ( "bytes" "go/ast" "go/parser" "go/printer" "go/token" "reflect" "strings" "testing" "visiona-local/server/internal/driver" "visiona-local/server/internal/model" ) // recordingDriver 記錄 Flash 收到的 opts,用來驗證 model metadata 有被傳下去。 type recordingDriver struct { gotPath string gotOpts driver.FlashOptions called bool } func (d *recordingDriver) Info() driver.DeviceInfo { return driver.DeviceInfo{} } func (d *recordingDriver) Connect() error { return nil } func (d *recordingDriver) Disconnect() error { return nil } func (d *recordingDriver) IsConnected() bool { return true } func (d *recordingDriver) Flash(modelPath string, opts driver.FlashOptions, _ chan<- driver.FlashProgress) error { d.called = true d.gotPath = modelPath d.gotOpts = opts return nil } func (d *recordingDriver) StartInference() error { return nil } func (d *recordingDriver) StopInference() error { return nil } func (d *recordingDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil } func (d *recordingDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil } func (d *recordingDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil } // flashOptionsFor 複製 StartFlash 內部組 FlashOptions 的邏輯。 // // 為什麼不直接跑 StartFlash:Service 依賴具體的 *device.Manager,而 Manager 的 // sessions map 未匯出、只能由真實硬體偵測填入,沒有注入 fake session 的接縫。 // 為了測試而改 production 的依賴結構超出 M2 範圍,所以這裡改為釘住「送進 // driver 的 FlashOptions 必須完整帶著 model 的 TaskType/Labels」這個契約。 func flashOptionsFor(m model.Model) driver.FlashOptions { return driver.FlashOptions{ TaskType: m.TaskType, Labels: m.Labels, InputWidth: m.InputSize.Width, InputHeight: m.InputSize.Height, } } // TestFlashOptions_CarriesClassificationMetadata:classification model 的 // taskType + labels 要完整傳到 driver,不能像改動前一樣只傳 path 就丟棄。 func TestFlashOptions_CarriesClassificationMetadata(t *testing.T) { m := model.Model{ ID: "custom-rps", TaskType: "classification", Labels: []string{"剪刀", "石頭", "布"}, } d := &recordingDriver{} opts := flashOptionsFor(m) if err := d.Flash("/models/custom-rps/model.nef", opts, nil); err != nil { t.Fatalf("Flash returned error: %v", err) } if !d.called { t.Fatal("Flash was not called") } if d.gotOpts.TaskType != "classification" { t.Errorf("TaskType = %q, want classification", d.gotOpts.TaskType) } if !reflect.DeepEqual(d.gotOpts.Labels, []string{"剪刀", "石頭", "布"}) { t.Errorf("Labels = %v, want [剪刀 石頭 布]", d.gotOpts.Labels) } } // TestFlashOptions_CarriesDetectionMetadata:既有 detection model 走同一條路, // taskType 為 object_detection —— bridge 端收到後仍走 detection 分支,行為不變。 func TestFlashOptions_CarriesDetectionMetadata(t *testing.T) { m := model.Model{ ID: "kl520-fcos-detection", TaskType: "object_detection", Labels: []string{"person", "bicycle", "car"}, } opts := flashOptionsFor(m) if opts.TaskType != "object_detection" { t.Errorf("TaskType = %q, want object_detection", opts.TaskType) } if len(opts.Labels) != 3 { t.Errorf("Labels = %v, want 3 entries", opts.Labels) } } // TestFlashOptions_EmptyMetadataIsZeroValue:model 沒宣告 taskType/labels 時 // 送出的是零值,driver 端會據此省略欄位,讓 bridge fallback 到既有 heuristics。 func TestFlashOptions_EmptyMetadataIsZeroValue(t *testing.T) { opts := flashOptionsFor(model.Model{ID: "bare"}) if opts.TaskType != "" { t.Errorf("TaskType = %q, want empty", opts.TaskType) } if len(opts.Labels) != 0 { t.Errorf("Labels = %v, want empty", opts.Labels) } } // TestStartFlashPassesModelMetadata 釘住 StartFlash 原始碼真的有把 m.TaskType / // m.Labels 傳進 Flash。 // // 上面的測試只驗「FlashOptions 帶得動 metadata」,無法防止有人把 service.go 改回 // 只傳 path —— 那正是改動前的 bug 形態(metadata 被靜默丟棄、不會報錯)。 // 這裡直接掃 service.go 的 StartFlash 本體補上這個缺口。 func TestStartFlashPassesModelMetadata(t *testing.T) { src := readStartFlashSource(t) for _, want := range []string{ "m.TaskType", "m.Labels", "driver.FlashOptions", // 宣告的 input size 也要傳下去 —— 沒傳的話 bridge 在 SDK 問不到 // shape 時只能靠檔名猜,那正是「尺寸靜默錯誤」的來源。 "m.InputSize.Width", "m.InputSize.Height", } { if !strings.Contains(src, want) { t.Errorf("StartFlash 原始碼缺少 %q —— model metadata 沒有被傳給 driver.Flash", want) } } } // TestFlashOptions_CarriesDeclaredInputSize:models.json 宣告的 inputSize 要 // 完整帶到 driver。改動前這個欄位在推論鏈路上完全沒被讀過 —— 使用者在上傳 // 表單填的寬高毫無作用,input size 全由檔名猜測決定。 func TestFlashOptions_CarriesDeclaredInputSize(t *testing.T) { m := model.Model{ ID: "custom-rps", TaskType: "classification", InputSize: model.InputSize{Width: 320, Height: 256}, } opts := flashOptionsFor(m) if opts.InputWidth != 320 { t.Errorf("InputWidth = %d, want 320", opts.InputWidth) } // 非正方形模型的高度不可被壓成寬度。 if opts.InputHeight != 256 { t.Errorf("InputHeight = %d, want 256", opts.InputHeight) } } // TestFlashOptions_MissingInputSizeIsZero:models.json 沒填 inputSize 時是零值, // driver 端會省略欄位,bridge 據此 fallback(既有 detection 模型走這條)。 func TestFlashOptions_MissingInputSizeIsZero(t *testing.T) { opts := flashOptionsFor(model.Model{ID: "bare"}) if opts.InputWidth != 0 || opts.InputHeight != 0 { t.Errorf("InputSize = %dx%d, want 0x0", opts.InputWidth, opts.InputHeight) } } // readStartFlashSource 取出 service.go 中 StartFlash 方法的原始碼文字。 func readStartFlashSource(t *testing.T) string { t.Helper() fset := token.NewFileSet() file, err := parser.ParseFile(fset, "service.go", nil, 0) if err != nil { t.Fatalf("parse service.go: %v", err) } for _, decl := range file.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Name.Name != "StartFlash" || fn.Recv == nil { continue } var buf bytes.Buffer if err := printer.Fprint(&buf, fset, fn); err != nil { t.Fatalf("print StartFlash: %v", err) } return buf.String() } t.Fatal("StartFlash method not found in service.go") return "" }