package kneron // set_inference_options_test.go — M4:推論期切換解析方式 / label 表的 // JSON-RPC payload 契約測試。 // // 這裡的核心不是「欄位名對不對」,而是 **nil / 空陣列 / 缺欄位三種狀態的 // 語意必須各自可表達**: // // 缺 task_type 欄位 → bridge 保留當前解析方式 // 缺 labels 欄位 → bridge 保留當前 label 表 // labels: [] → bridge 清空 label 表(回到原始 enum) // // 若照 load_model 的規則用 `len(labels) > 0` 判斷是否放進 payload,第三種 // 狀態就永遠送不出去 —— 使用者按「清除標籤」會拿到 200 但什麼都沒發生。 // 這正是這個功能要防的靜默失敗,所以逐條釘死。 import ( "encoding/json" "strings" "testing" "visiona-local/server/internal/driver" ) func TestBuildSetInferenceOptionsCommand_CommandName(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{TaskType: "classification"}) // 必須與 kneron_bridge.py main() dispatch 的字串完全一致。 if cmd["cmd"] != "set_inference_options" { t.Errorf("cmd = %v, want set_inference_options", cmd["cmd"]) } } func TestBuildSetInferenceOptionsCommand_TaskTypeOnly(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{TaskType: "classification"}) if cmd["task_type"] != "classification" { t.Errorf("task_type = %v, want classification", cmd["task_type"]) } if _, present := cmd["labels"]; present { t.Error("沒指定 labels 時不可放進 payload —— bridge 會誤以為要改 label 表") } } func TestBuildSetInferenceOptionsCommand_LabelsOnly(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{ Labels: []string{"剪刀", "石頭", "布"}, }) if _, present := cmd["task_type"]; present { t.Error("沒指定 task_type 時不可放進 payload —— bridge 會誤以為要切解析方式") } labels, ok := cmd["labels"].([]string) if !ok { t.Fatalf("labels type = %T, want []string", cmd["labels"]) } if len(labels) != 3 || labels[0] != "剪刀" { t.Errorf("labels = %v", labels) } } // ⭐ 本檔最重要的一條:空陣列必須真的被送出去。 func TestBuildSetInferenceOptionsCommand_EmptyLabelsIsSent(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{ Labels: []string{}, }) raw, present := cmd["labels"] if !present { t.Fatal("空 labels 陣列沒被送出 —— 「清空 label 表」的意圖丟失了。" + "(是不是用 len(opts.Labels) > 0 判斷?要用 != nil)") } labels, ok := raw.([]string) if !ok { t.Fatalf("labels type = %T, want []string", raw) } if len(labels) != 0 { t.Errorf("len(labels) = %d, want 0", len(labels)) } } // 對照組:nil 才代表「不動」。 func TestBuildSetInferenceOptionsCommand_NilLabelsIsOmitted(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{ TaskType: "classification", Labels: nil, }) if _, present := cmd["labels"]; present { t.Error("nil labels 不可放進 payload —— nil 代表「保留當前 label 表」") } } // 序列化後空陣列要是 JSON 的 [],不能變成 null。 // bridge 端對 null 與 [] 的處理不同:null → 保留(依 handler 邏輯 pending=None)、 // [] → 清空。變成 null 會讓清空意圖在 wire 上就失真。 func TestBuildSetInferenceOptionsCommand_EmptyLabelsMarshalsToArray(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{Labels: []string{}}) data, err := json.Marshal(cmd) if err != nil { t.Fatalf("marshal: %v", err) } var decoded map[string]interface{} if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("unmarshal: %v", err) } raw, present := decoded["labels"] if !present { t.Fatalf("labels 欄位不見了:%s", data) } arr, ok := raw.([]interface{}) if !ok { t.Fatalf("labels 序列化成 %T(%s),want JSON array —— null 會被 bridge "+ "解讀成「不動」而非「清空」", raw, data) } if len(arr) != 0 { t.Errorf("len = %d, want 0", len(arr)) } } func TestBuildSetInferenceOptionsCommand_Both(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{ TaskType: "object_detection", Labels: []string{"a", "", "c"}, }) if cmd["task_type"] != "object_detection" { t.Errorf("task_type = %v", cmd["task_type"]) } labels, ok := cmd["labels"].([]string) if !ok { t.Fatalf("labels type = %T", cmd["labels"]) } // 稀疏佔位的空字串要原樣送過去 —— 位置就是 class index, // 壓縮掉會讓所有後面的 index 位移。 if len(labels) != 3 || labels[1] != "" { t.Errorf("labels = %v, want [a c](稀疏佔位必須保留)", labels) } } // 全空的 options 不該被 builder 擋(那是上層 handler 的責任),但也不該 // 憑空生出欄位 —— 只帶 cmd。這條確保 builder 保持「純翻譯」不做決策。 func TestBuildSetInferenceOptionsCommand_ZeroValueOnlyHasCmd(t *testing.T) { cmd := buildSetInferenceOptionsCommand(driver.InferenceOptions{}) if len(cmd) != 1 { t.Errorf("payload = %v, want 只有 cmd 一個鍵", cmd) } } // ── driver 前置條件 ────────────────────────────────────────────────── func TestSetInferenceOptions_RequiresBridge(t *testing.T) { d := &KneronDriver{} err := d.SetInferenceOptions(driver.InferenceOptions{TaskType: "classification"}) if err == nil { t.Fatal("bridge 沒跑時應該回錯") } if !strings.Contains(err.Error(), "bridge is not running") { t.Errorf("err = %v, 應說明 bridge 未執行", err) } } func TestSetInferenceOptions_RequiresLoadedModel(t *testing.T) { // bridge 就緒但沒載 model:切解析方式沒有意義,且 bridge 端也會拒絕。 // 在 driver 層先擋掉,錯誤訊息才能指出「要先燒錄模型」。 d := &KneronDriver{pythonReady: true} err := d.SetInferenceOptions(driver.InferenceOptions{TaskType: "classification"}) if err == nil { t.Fatal("沒載 model 時應該回錯") } if !strings.Contains(err.Error(), "no model loaded") { t.Errorf("err = %v, 應說明尚未載入模型", err) } }