visionA/local-tool/server/internal/driver/kneron/set_inference_options_test.go
jim800121chen 4583406efa feat(api): 推論期切換解析方式與上傳 label 檔
新增 POST /api/devices/:id/inference/options,讓已燒錄的模型不重燒
即可切換 detection/classification 與更換 label。

KL520 一次只能載一個模型(換模型必須重燒),但解析方式與 label 都
只是後處理與顯示層,不碰 NPU,因此可即時生效。

- JSON: {taskType?, labels?};multipart: taskType + labelFile
- 欄位用指標型別以區分「省略 = 不動」與「labels:[] = 清空」
- label 解析器(internal/labelfile):格式 <index> <名稱>
  略過空行與 # 註解、trim CRLF、名稱含空白只 split 第一個空白、
  index 非整數/負數/重複則整檔拒絕並回報行號、稀疏 index 補空字串
- index 上限 4095、檔案上限 256KB,防記憶體耗盡
- 非法 taskType 一律拒絕(含舊別名 detection),因為 bridge 對無法
  辨識的值是靜默 fallback,忽略會回 200 但用錯解析方式
- 走窄能力介面 InferenceOptionsDriver,不動 DeviceDriver 簽章

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 19:27:16 +08:00

181 lines
6.1 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 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%swant 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)
}
}