package handlers // device_inference_options_test.go — M4:POST /api/devices/:id/inference/options // 的 HTTP 契約測試(推論期切換解析方式 / label 表)。 // // 測試分兩層: // // 1. parseInferenceOptionsRequest — 這裡是本 endpoint 幾乎全部的邏輯 // (JSON / multipart 解析、值域驗證、大小上限、nil vs 空陣列的語意)。 // 它只依賴 *gin.Context,可以完整單元測試。 // 2. SetInferenceOptions handler — 只驗它自己負責的分支:device 不存在、 // driver 不支援、driver 回錯。 // // ⚠️ 測試接縫限制(與 device_flash_tasktype_test.go 同一個既有問題): // DeviceHandler.deviceMgr 是具體的 *device.Manager,其 sessions map 未匯出、 // 只能由真實硬體填入,跨 package 無法注入 fake session。因此「成功路徑打到 // driver」這段沒有 handler 級測試 —— 由 buildSetInferenceOptionsCommand 的 // 單元測試(driver/kneron)與實機驗收覆蓋。這是既有架構限制,不是本次引入。 import ( "bytes" "encoding/json" "fmt" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "visiona-local/server/internal/device" "visiona-local/server/internal/driver" "visiona-local/server/internal/driver/kneron" "visiona-local/server/internal/labelfile" "github.com/gin-gonic/gin" ) // ── helpers ────────────────────────────────────────────────────────── // errorCode 取出統一錯誤格式裡的 error.code。 // // 原先住在 device_flash_tasktype_test.go;該檔隨「燒錄時選推論種類」功能一起 // 移除後搬來這裡(本檔是目前唯一的使用者)。 func errorCode(parsed map[string]interface{}) string { errObj, ok := parsed["error"].(map[string]interface{}) if !ok { return "" } code, _ := errObj["code"].(string) return code } // runParse 以指定的 body / content-type 呼叫 parseInferenceOptionsRequest。 func runParse(t *testing.T, contentType string, body []byte) (driver.InferenceOptions, labelSummary, *apiError) { t.Helper() var ( opts driver.InferenceOptions summary labelSummary apiErr *apiError ) router := gin.New() router.POST("/x", func(c *gin.Context) { opts, summary, apiErr = parseInferenceOptionsRequest(c) c.Status(http.StatusOK) }) req := httptest.NewRequest(http.MethodPost, "/x", bytes.NewReader(body)) req.Header.Set("Content-Type", contentType) router.ServeHTTP(httptest.NewRecorder(), req) return opts, summary, apiErr } func parseJSON(t *testing.T, body string) (driver.InferenceOptions, labelSummary, *apiError) { t.Helper() return runParse(t, "application/json", []byte(body)) } // buildMultipart 組出 multipart body(labelFileName 為空表示不帶檔案)。 func buildMultipart(t *testing.T, taskType, labelFileName, labelContent string) (string, []byte) { t.Helper() var buf bytes.Buffer w := multipart.NewWriter(&buf) if taskType != "" { if err := w.WriteField("taskType", taskType); err != nil { t.Fatalf("WriteField: %v", err) } } if labelFileName != "" { fw, err := w.CreateFormFile("labelFile", labelFileName) if err != nil { t.Fatalf("CreateFormFile: %v", err) } if _, err := fw.Write([]byte(labelContent)); err != nil { t.Fatalf("write file part: %v", err) } } if err := w.Close(); err != nil { t.Fatalf("close writer: %v", err) } return w.FormDataContentType(), buf.Bytes() } func requireNoAPIError(t *testing.T, err *apiError) { t.Helper() if err != nil { t.Fatalf("unexpected apiError: code=%s message=%s", err.code, err.message) } } func requireAPIError(t *testing.T, err *apiError, wantCode string) *apiError { t.Helper() if err == nil { t.Fatalf("expected apiError with code %s, got nil", wantCode) } if err.code != wantCode { t.Fatalf("error code = %q, want %q (message=%q)", err.code, wantCode, err.message) } return err } // ── JSON body ──────────────────────────────────────────────────────── func TestParseInferenceOptions_JSON_TaskTypeOnly(t *testing.T) { opts, _, err := parseJSON(t, `{"taskType":"classification"}`) requireNoAPIError(t, err) if opts.TaskType != "classification" { t.Errorf("TaskType = %q, want classification", opts.TaskType) } if opts.Labels != nil { t.Errorf("Labels = %v, want nil(沒帶 labels 就不該動 label 表)", opts.Labels) } } func TestParseInferenceOptions_JSON_LabelsOnly(t *testing.T) { opts, summary, err := parseJSON(t, `{"labels":["剪刀","石頭","布"]}`) requireNoAPIError(t, err) if opts.TaskType != "" { t.Errorf("TaskType = %q, want 空(沒帶就不該動解析方式)", opts.TaskType) } want := []string{"剪刀", "石頭", "布"} if !equalStringSlice(opts.Labels, want) { t.Errorf("Labels = %v, want %v", opts.Labels, want) } if summary.namedCount != 3 { t.Errorf("namedCount = %d, want 3", summary.namedCount) } } // 關鍵語意:空陣列 = 清空 label 表,必須與「沒帶欄位」區分。 func TestParseInferenceOptions_JSON_EmptyLabelsMeansClear(t *testing.T) { opts, summary, err := parseJSON(t, `{"labels":[]}`) requireNoAPIError(t, err) if opts.Labels == nil { t.Fatal("Labels = nil —— 空陣列被塌成 nil,「清空 label 表」的意圖丟失了") } if len(opts.Labels) != 0 { t.Errorf("len(Labels) = %d, want 0", len(opts.Labels)) } if summary.namedCount != 0 { t.Errorf("namedCount = %d, want 0", summary.namedCount) } } // 對照組:沒帶 labels 欄位時 Labels 必須是 nil(= 不動)。 func TestParseInferenceOptions_JSON_AbsentLabelsMeansUnchanged(t *testing.T) { opts, _, err := parseJSON(t, `{"taskType":"classification"}`) requireNoAPIError(t, err) if opts.Labels != nil { t.Errorf("Labels = %v, want nil", opts.Labels) } } // JSON null 與「沒帶」同義 —— 都是不動。 func TestParseInferenceOptions_JSON_NullLabelsMeansUnchanged(t *testing.T) { opts, _, err := parseJSON(t, `{"taskType":"classification","labels":null}`) requireNoAPIError(t, err) if opts.Labels != nil { t.Errorf("Labels = %v, want nil", opts.Labels) } } func TestParseInferenceOptions_JSON_Both(t *testing.T) { opts, summary, err := parseJSON(t, `{"taskType":"classification","labels":["a","b"]}`) requireNoAPIError(t, err) if opts.TaskType != "classification" { t.Errorf("TaskType = %q", opts.TaskType) } if summary.namedCount != 2 { t.Errorf("namedCount = %d, want 2", summary.namedCount) } } func TestParseInferenceOptions_JSON_SparseLabelsCountedCorrectly(t *testing.T) { opts, summary, err := parseJSON(t, `{"labels":["a","","c"]}`) requireNoAPIError(t, err) if len(opts.Labels) != 3 { t.Errorf("len(Labels) = %d, want 3(稀疏佔位要保留,位置就是 class index)", len(opts.Labels)) } if summary.namedCount != 2 { t.Errorf("namedCount = %d, want 2(空字串不算一筆)", summary.namedCount) } } func TestParseInferenceOptions_JSON_MalformedBody(t *testing.T) { _, _, err := parseJSON(t, `{not json`) requireAPIError(t, err, "BAD_REQUEST") } // ── 值域驗證(沿用燒錄時同一套)────────────────────────────────────── func TestParseInferenceOptions_RejectsInvalidTaskType(t *testing.T) { // R-4:舊別名 detection 也要擋 —— bridge 收得下,但不讓兩套命名同時 // 出現在 wire 上。與 POST /flash 的規則保持完全一致。 bad := []string{ "detection", "segmentation", "pose_estimation", "Classification", "classifcation", "garbage", } for _, tt := range bad { t.Run(tt, func(t *testing.T) { body := fmt.Sprintf(`{"taskType":%q}`, tt) _, _, err := parseJSON(t, body) e := requireAPIError(t, err, "VALIDATION_ERROR") if e.status != http.StatusBadRequest { t.Errorf("status = %d, want 400", e.status) } if !strings.Contains(e.message, "classification") || !strings.Contains(e.message, "object_detection") { t.Errorf("message = %q, 應列出合法值", e.message) } }) } } func TestParseInferenceOptions_AcceptsValidTaskType(t *testing.T) { for _, tt := range []string{"classification", "object_detection"} { t.Run(tt, func(t *testing.T) { opts, _, err := parseJSON(t, fmt.Sprintf(`{"taskType":%q}`, tt)) requireNoAPIError(t, err) if opts.TaskType != tt { t.Errorf("TaskType = %q, want %q", opts.TaskType, tt) } }) } } // 兩者都沒帶 = 呼叫端沒表達任何意圖。回 200 等於假裝做了事。 func TestParseInferenceOptions_RejectsEmptyRequest(t *testing.T) { cases := map[string]string{ "空物件": `{}`, "taskType 空字串": `{"taskType":""}`, "兩者皆 null": `{"taskType":null,"labels":null}`, } for name, body := range cases { t.Run(name, func(t *testing.T) { _, _, err := parseJSON(t, body) requireAPIError(t, err, "BAD_REQUEST") }) } } // 只帶空 labels 陣列是有意義的(清空),不該被「空請求」規則誤擋。 func TestParseInferenceOptions_EmptyLabelsAloneIsNotEmptyRequest(t *testing.T) { _, _, err := parseJSON(t, `{"labels":[]}`) requireNoAPIError(t, err) } // ── JSON labels 數量上限(S-2 / R-7)───────────────────────────────── func TestParseInferenceOptions_JSON_RejectsTooManyLabels(t *testing.T) { labels := make([]string, labelfile.MaxIndex+2) for i := range labels { labels[i] = "x" } payload, err := json.Marshal(map[string]interface{}{"labels": labels}) if err != nil { t.Fatalf("marshal: %v", err) } _, _, apiErr := runParse(t, "application/json", payload) requireAPIError(t, apiErr, "LABEL_TOO_LARGE") } func TestParseInferenceOptions_JSON_AcceptsExactlyMaxLabels(t *testing.T) { labels := make([]string, labelfile.MaxIndex+1) for i := range labels { labels[i] = "x" } payload, err := json.Marshal(map[string]interface{}{"labels": labels}) if err != nil { t.Fatalf("marshal: %v", err) } _, _, apiErr := runParse(t, "application/json", payload) requireNoAPIError(t, apiErr) } // ── multipart 上傳 ─────────────────────────────────────────────────── func TestParseInferenceOptions_Multipart_LabelFile(t *testing.T) { ct, body := buildMultipart(t, "classification", "labels.txt", "0 剪刀\n1 石頭\n2 布\n") opts, summary, err := runParse(t, ct, body) requireNoAPIError(t, err) if opts.TaskType != "classification" { t.Errorf("TaskType = %q", opts.TaskType) } want := []string{"剪刀", "石頭", "布"} if !equalStringSlice(opts.Labels, want) { t.Errorf("Labels = %v, want %v", opts.Labels, want) } if summary.namedCount != 3 { t.Errorf("namedCount = %d, want 3", summary.namedCount) } } func TestParseInferenceOptions_Multipart_TaskTypeOnlyNoFile(t *testing.T) { ct, body := buildMultipart(t, "object_detection", "", "") opts, _, err := runParse(t, ct, body) requireNoAPIError(t, err) if opts.TaskType != "object_detection" { t.Errorf("TaskType = %q", opts.TaskType) } if opts.Labels != nil { t.Errorf("Labels = %v, want nil(沒上傳檔案就不該動 label 表)", opts.Labels) } } func TestParseInferenceOptions_Multipart_SparseLabelFile(t *testing.T) { ct, body := buildMultipart(t, "classification", "labels.txt", "0 a\n3 d\n") opts, summary, err := runParse(t, ct, body) requireNoAPIError(t, err) want := []string{"a", "", "", "d"} if !equalStringSlice(opts.Labels, want) { t.Errorf("Labels = %v, want %v", opts.Labels, want) } if summary.namedCount != 2 { t.Errorf("namedCount = %d, want 2", summary.namedCount) } } // 解析失敗要帶行號回去,前端才能指出是哪一行。 func TestParseInferenceOptions_Multipart_ParseErrorCarriesLine(t *testing.T) { ct, body := buildMultipart(t, "", "labels.txt", "0 a\n1 b\nabc c\n") _, _, err := runParse(t, ct, body) e := requireAPIError(t, err, "LABEL_PARSE_ERROR") if e.line != 3 { t.Errorf("line = %d, want 3", e.line) } if _, ok := e.body()["line"].(int); !ok { t.Error("回應 body 應包含 line 欄位") } } func TestParseInferenceOptions_Multipart_ParseErrorBodyOmitsLineWhenZero(t *testing.T) { ct, body := buildMultipart(t, "", "labels.txt", "\n\n\n") _, _, err := runParse(t, ct, body) e := requireAPIError(t, err, "LABEL_PARSE_ERROR") if _, present := e.body()["line"]; present { t.Error("與行號無關的錯誤不應帶 line 欄位(前端會亂標第 0 行)") } } func TestParseInferenceOptions_Multipart_RejectsOversizedFile(t *testing.T) { big := strings.Repeat("0 aaaaaaaa\n", labelfile.MaxFileSize/10+100) ct, body := buildMultipart(t, "", "labels.txt", big) _, _, err := runParse(t, ct, body) requireAPIError(t, err, "LABEL_TOO_LARGE") } func TestParseInferenceOptions_Multipart_RejectsBadExtension(t *testing.T) { ct, body := buildMultipart(t, "", "labels.exe", "0 a\n") _, _, err := runParse(t, ct, body) requireAPIError(t, err, "BAD_REQUEST") } func TestParseInferenceOptions_Multipart_AcceptsNamesExtension(t *testing.T) { ct, body := buildMultipart(t, "", "coco.names", "0 person\n") opts, _, err := runParse(t, ct, body) requireNoAPIError(t, err) if !equalStringSlice(opts.Labels, []string{"person"}) { t.Errorf("Labels = %v", opts.Labels) } } func TestParseInferenceOptions_Multipart_RejectsInvalidTaskType(t *testing.T) { ct, body := buildMultipart(t, "detection", "labels.txt", "0 a\n") _, _, err := runParse(t, ct, body) requireAPIError(t, err, "VALIDATION_ERROR") } func TestParseInferenceOptions_Multipart_RejectsEmptyRequest(t *testing.T) { ct, body := buildMultipart(t, "", "", "") _, _, err := runParse(t, ct, body) requireAPIError(t, err, "BAD_REQUEST") } // 上傳的 index 超過上限 → 由 labelfile 擋下並回 LABEL_PARSE_ERROR。 // 若這道防線失效,handler 會嘗試配置巨大 slice。 func TestParseInferenceOptions_Multipart_RejectsHugeIndex(t *testing.T) { ct, body := buildMultipart(t, "", "labels.txt", "999999999 boom\n") _, _, err := runParse(t, ct, body) requireAPIError(t, err, "LABEL_PARSE_ERROR") } // ── handler 分支(不需要 device session 的部分)────────────────────── // postOptions 呼叫 SetInferenceOptions 並回傳 status / 解析後 body。 func postOptions(t *testing.T, h *DeviceHandler, contentType string, body []byte) (int, map[string]interface{}) { t.Helper() router := gin.New() router.POST("/devices/:id/inference/options", h.SetInferenceOptions) req := httptest.NewRequest(http.MethodPost, "/devices/dev-1/inference/options", bytes.NewReader(body)) req.Header.Set("Content-Type", contentType) w := httptest.NewRecorder() router.ServeHTTP(w, req) parsed := map[string]interface{}{} _ = json.Unmarshal(w.Body.Bytes(), &parsed) return w.Code, parsed } func TestSetInferenceOptions_DeviceNotFound(t *testing.T) { h := &DeviceHandler{deviceMgr: device.NewManager(device.NewRegistry(), "")} status, parsed := postOptions(t, h, "application/json", []byte(`{"taskType":"classification"}`)) if status != http.StatusNotFound { t.Errorf("status = %d, want 404", status) } if got := errorCode(parsed); got != "DEVICE_NOT_FOUND" { t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", got) } } // device 查找必須排在 body 解析之前 —— 對不存在的裝置回「body 有問題」 // 會把使用者引去改 payload,而真正的問題是裝置不在。 func TestSetInferenceOptions_DeviceLookupPrecedesBodyValidation(t *testing.T) { h := &DeviceHandler{deviceMgr: device.NewManager(device.NewRegistry(), "")} status, parsed := postOptions(t, h, "application/json", []byte(`{not json`)) if status != http.StatusNotFound { t.Errorf("status = %d, want 404(裝置查找應先於 body 解析)", status) } if got := errorCode(parsed); got != "DEVICE_NOT_FOUND" { t.Errorf("error.code = %q, want DEVICE_NOT_FOUND", got) } } // ── 介面契約 ───────────────────────────────────────────────────────── // *kneron.KneronDriver 必須真的滿足 InferenceOptionsDriver。 // // 這是整條鏈路唯一會「靜默壞掉」的接點:handler 用 type assert 取得能力, // 若 driver 的 method 簽章改了(或被誤刪),編譯完全不會報錯 —— endpoint // 會對所有裝置回 UNSUPPORTED_DEVICE,而且只有實機才看得出來。 // // 刻意 assert 具體型別而非自己寫一個滿足介面的 stub:stub 只證明「我寫的 // stub 符合我寫的介面」,對真正的實作零保障。 var _ InferenceOptionsDriver = (*kneron.KneronDriver)(nil) // KneronDriver 同時必須仍是合法的 driver.DeviceDriver —— 加新能力不能 // 破壞既有契約。 var _ driver.DeviceDriver = (*kneron.KneronDriver)(nil) // ── helper ─────────────────────────────────────────────────────────── func equalStringSlice(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }