models.json 宣告的 taskType/labels/inputSize 原本在 flash 時被丟棄 (只傳 modelPath),導致 bridge 只能靠檔名猜測模型類型與尺寸。 - FlashOptions 帶 TaskType/Labels/InputWidth/InputHeight - 抽出 buildLoadModelCommand,四處 load_model 呼叫點(初次 + 三條 retry 路徑)統一走它,並加測試釘住呼叫點數量與「不得有手寫 payload」 —— 讓漏改 retry 路徑在結構上不可能發生 - ClassResult 加 ClassIndex(不加 omitempty,index 0 是合法值) 用 FlashOptions struct 而非裸參數,未來擴充欄位不需再動 interface 與所有 test fake。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
6.6 KiB
Go
191 lines
6.6 KiB
Go
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 ""
|
||
}
|