visionA/local-tool/server/internal/api/handlers/device_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

501 lines
17 KiB
Go
Raw Permalink 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 handlers
// device_inference_options_test.go — M4POST /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 bodylabelFileName 為空表示不帶檔案)。
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 具體型別而非自己寫一個滿足介面的 stubstub 只證明「我寫的
// 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
}