visionA/local-tool/server/internal/flash/service_metadata_test.go
jim800121chen ddd1aae5d1 feat(server): 把 model metadata 傳進推論鏈路
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>
2026-07-22 19:27:02 +08:00

191 lines
6.6 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 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 的邏輯。
//
// 為什麼不直接跑 StartFlashService 依賴具體的 *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_CarriesClassificationMetadataclassification 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_EmptyMetadataIsZeroValuemodel 沒宣告 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_CarriesDeclaredInputSizemodels.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_MissingInputSizeIsZeromodels.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 ""
}