From 4583406efa3169c4b8df34143bbb72a2138b8a4f Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Wed, 22 Jul 2026 19:27:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=8E=A8=E8=AB=96=E6=9C=9F?= =?UTF-8?q?=E5=88=87=E6=8F=9B=E8=A7=A3=E6=9E=90=E6=96=B9=E5=BC=8F=E8=88=87?= =?UTF-8?q?=E4=B8=8A=E5=82=B3=20label=20=E6=AA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /api/devices/:id/inference/options,讓已燒錄的模型不重燒 即可切換 detection/classification 與更換 label。 KL520 一次只能載一個模型(換模型必須重燒),但解析方式與 label 都 只是後處理與顯示層,不碰 NPU,因此可即時生效。 - JSON: {taskType?, labels?};multipart: taskType + labelFile - 欄位用指標型別以區分「省略 = 不動」與「labels:[] = 清空」 - label 解析器(internal/labelfile):格式 <名稱> 略過空行與 # 註解、trim CRLF、名稱含空白只 split 第一個空白、 index 非整數/負數/重複則整檔拒絕並回報行號、稀疏 index 補空字串 - index 上限 4095、檔案上限 256KB,防記憶體耗盡 - 非法 taskType 一律拒絕(含舊別名 detection),因為 bridge 對無法 辨識的值是靜默 fallback,忽略會回 200 但用錯解析方式 - 走窄能力介面 InferenceOptionsDriver,不動 DeviceDriver 簽章 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/api/handlers/device_handler.go | 291 ++++++++++ .../handlers/device_inference_options_test.go | 500 ++++++++++++++++++ local-tool/server/internal/api/router.go | 3 + .../kneron/set_inference_options_test.go | 180 +++++++ local-tool/server/internal/labelfile/parse.go | 233 ++++++++ .../server/internal/labelfile/parse_test.go | 423 +++++++++++++++ 6 files changed, 1630 insertions(+) create mode 100644 local-tool/server/internal/api/handlers/device_inference_options_test.go create mode 100644 local-tool/server/internal/driver/kneron/set_inference_options_test.go create mode 100644 local-tool/server/internal/labelfile/parse.go create mode 100644 local-tool/server/internal/labelfile/parse_test.go diff --git a/local-tool/server/internal/api/handlers/device_handler.go b/local-tool/server/internal/api/handlers/device_handler.go index c2026a7..8d8cc0f 100644 --- a/local-tool/server/internal/api/handlers/device_handler.go +++ b/local-tool/server/internal/api/handlers/device_handler.go @@ -2,9 +2,14 @@ package handlers import ( "context" + "errors" "fmt" + "io" + "net/http" "os" + "path/filepath" "runtime" + "strings" "time" "visiona-local/server/internal/api/ws" @@ -12,6 +17,7 @@ import ( "visiona-local/server/internal/driver" "visiona-local/server/internal/flash" "visiona-local/server/internal/inference" + "visiona-local/server/internal/labelfile" "github.com/gin-gonic/gin" ) @@ -175,6 +181,8 @@ func (h *DeviceHandler) DisconnectDevice(c *gin.Context) { func (h *DeviceHandler) FlashDevice(c *gin.Context) { id := c.Param("id") + // 燒錄只需要 modelId。推論種類一律用 models.json 宣告的值;要改解析方式 + // 走 POST /devices/:id/inference/options(推論期即時切換、不必重燒)。 var req struct { ModelID string `json:"modelId"` } @@ -207,6 +215,289 @@ func (h *DeviceHandler) FlashDevice(c *gin.Context) { c.JSON(200, gin.H{"success": true, "data": gin.H{"taskId": taskID}}) } +// InferenceOptionsDriver 是「支援推論期切換解析方式」的 driver 能力介面。 +// +// 為什麼不直接加進 driver.DeviceDriver:那是所有 driver 都必須實作的最小 +// 契約,加一個 Kneron 特有能力進去,三個既有 test fake 全都要跟著改,而它們 +// 跟這個功能完全無關。用窄介面 + type assert 是這個 repo 已建立的做法 +// (見 firmware.UpgradeDriver / DeviceManagerAdapter.GetUpgradeDriver)。 +type InferenceOptionsDriver interface { + SetInferenceOptions(opts driver.InferenceOptions) error +} + +// inferenceOptionsRequest 是 JSON 形式的 request body。 +// +// 兩個欄位都是指標,因為必須區分「沒帶這個欄位」與「帶了空值」: +// +// Labels == nil → 不動 label 表 +// Labels == &[]string{} → 清空 label 表(回到原始 enum) +// +// 用非指標 []string 的話 JSON 的 `null`、`[]` 與「欄位不存在」會全部塌成 +// nil,「清空」這個合法意圖就永遠表達不出來。 +type inferenceOptionsRequest struct { + TaskType *string `json:"taskType"` + Labels *[]string `json:"labels"` +} + +// SetInferenceOptions 在不重新燒錄的前提下,更新當前已載入模型的解析方式 +// 與 label 表。 +// +// POST /api/devices/:id/inference/options +// +// 支援兩種 content type: +// +// application/json — {"taskType": "...", "labels": [...]} +// multipart/form-data — taskType 欄位 + labelFile 檔案(` <名稱>`) +// +// 刻意不做任何持久化:使用者明確要求「不用記,每次現場傳」。設定只存在於 +// 當前 bridge session,disconnect / reset / 重新 flash 都會清掉。 +func (h *DeviceHandler) SetInferenceOptions(c *gin.Context) { + id := c.Param("id") + + session, err := h.deviceMgr.GetDevice(id) + if err != nil { + c.JSON(404, gin.H{ + "success": false, + "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}, + }) + return + } + + optsDrv, ok := session.Driver.(InferenceOptionsDriver) + if !ok { + c.JSON(400, gin.H{ + "success": false, + "error": gin.H{ + "code": "UNSUPPORTED_DEVICE", + "message": "this device driver does not support runtime inference options", + }, + }) + return + } + + opts, labelInfo, apiErr := parseInferenceOptionsRequest(c) + if apiErr != nil { + c.JSON(apiErr.status, gin.H{"success": false, "error": apiErr.body()}) + return + } + + if err := optsDrv.SetInferenceOptions(opts); err != nil { + c.JSON(400, gin.H{ + "success": false, + "error": gin.H{"code": "INFERENCE_OPTIONS_FAILED", "message": err.Error()}, + }) + return + } + + data := gin.H{ + "deviceId": id, + "taskType": opts.TaskType, + } + if opts.Labels != nil { + data["labelCount"] = labelInfo.namedCount + data["labels"] = opts.Labels + if len(opts.Labels) > 0 { + data["maxIndex"] = len(opts.Labels) - 1 + } + } + c.JSON(200, gin.H{"success": true, "data": data}) +} + +// apiError 讓 parse 階段能同時回「HTTP status + 錯誤碼 + 可選的行號」。 +type apiError struct { + status int + code string + message string + // line 為 label 檔解析失敗的行號;0 表示與行號無關、不放進回應。 + line int +} + +func (e *apiError) body() gin.H { + h := gin.H{"code": e.code, "message": e.message} + if e.line > 0 { + h["line"] = e.line + } + return h +} + +// labelSummary 帶回 handler 要回報給前端的 label 統計。 +type labelSummary struct { + // namedCount 是實際有名稱的筆數(不含稀疏補洞的空字串)。 + namedCount int +} + +// parseInferenceOptionsRequest 從 JSON 或 multipart 取出設定並完整驗證。 +func parseInferenceOptionsRequest(c *gin.Context) (driver.InferenceOptions, labelSummary, *apiError) { + var opts driver.InferenceOptions + var summary labelSummary + + contentType := c.ContentType() + if strings.HasPrefix(contentType, "multipart/form-data") { + var err *apiError + opts, summary, err = parseMultipartInferenceOptions(c) + if err != nil { + return opts, summary, err + } + } else { + var req inferenceOptionsRequest + if bindErr := c.ShouldBindJSON(&req); bindErr != nil { + return opts, summary, &apiError{ + status: 400, + code: "BAD_REQUEST", + message: "invalid JSON body: " + bindErr.Error(), + } + } + if req.TaskType != nil { + opts.TaskType = *req.TaskType + } + if req.Labels != nil { + // 顯式給了 labels(含空陣列)→ 一律送出。空陣列 = 清空, + // 必須與「沒帶欄位」區分開。 + labels := *req.Labels + if labels == nil { + labels = []string{} + } + if len(labels) > labelfile.MaxIndex+1 { + return opts, summary, &apiError{ + status: 400, + code: "LABEL_TOO_LARGE", + message: fmt.Sprintf("labels 筆數 %d 超過上限 %d", len(labels), labelfile.MaxIndex+1), + } + } + opts.Labels = labels + summary.namedCount = countNamedLabels(labels) + } + } + + // taskType 值域用 flash.IsValidTaskTypeOverride —— 這裡是目前唯一讓使用者 + // 指定解析方式的入口(燒錄時不再選,一律用 models.json 宣告值)。舊別名 + // detection 一樣拒絕 —— bridge 收得下,但不讓兩套命名同時出現在 wire 上(R-4)。 + if opts.TaskType != "" && !flash.IsValidTaskTypeOverride(opts.TaskType) { + return opts, summary, &apiError{ + status: 400, + code: "VALIDATION_ERROR", + message: fmt.Sprintf("invalid taskType %q: must be %s or %s", + opts.TaskType, flash.TaskTypeObjectDetection, flash.TaskTypeClassification), + } + } + + // 兩者都沒帶 = 呼叫端沒表達任何意圖。回 200 等於假裝做了事,正是這個 + // 功能要防的靜默失敗,所以擋在這裡。 + if opts.TaskType == "" && opts.Labels == nil { + return opts, summary, &apiError{ + status: 400, + code: "BAD_REQUEST", + message: "至少要提供 taskType 或 labels 其中一項", + } + } + + return opts, summary, nil +} + +// parseMultipartInferenceOptions 處理 multipart 上傳(taskType 欄位 + labelFile 檔案)。 +func parseMultipartInferenceOptions(c *gin.Context) (driver.InferenceOptions, labelSummary, *apiError) { + var opts driver.InferenceOptions + var summary labelSummary + + // 限制 multipart 在記憶體中的暫存量;超過的部分 gin 會落地成暫存檔, + // 但真正的防線是下方的 header.Size 檢查。 + if err := c.Request.ParseMultipartForm(labelfile.MaxFileSize); err != nil { + return opts, summary, &apiError{ + status: 400, + code: "BAD_REQUEST", + message: "invalid multipart form: " + err.Error(), + } + } + + opts.TaskType = c.PostForm("taskType") + + file, header, err := c.Request.FormFile("labelFile") + if err != nil { + // 沒有檔案是合法的(只切 taskType)。其他錯誤才算壞請求。 + if errors.Is(err, http.ErrMissingFile) { + return opts, summary, nil + } + return opts, summary, &apiError{ + status: 400, + code: "BAD_REQUEST", + message: "failed to read labelFile: " + err.Error(), + } + } + defer file.Close() + + // 大小上限在讀取「之前」擋,不能讀完再判斷 —— 那時記憶體已經吃掉了。 + if header.Size > labelfile.MaxFileSize { + return opts, summary, &apiError{ + status: 400, + code: "LABEL_TOO_LARGE", + message: fmt.Sprintf("標籤檔過大(%d bytes),上限為 %d bytes", + header.Size, labelfile.MaxFileSize), + } + } + + // 副檔名檢查純粹防呆(真正的防線是內容解析)。上傳檔名只用來看副檔名, + // 不參與任何路徑組合 —— 本 endpoint 不落地存檔,沒有路徑穿越面。 + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != "" && ext != ".txt" && ext != ".names" { + return opts, summary, &apiError{ + status: 400, + code: "BAD_REQUEST", + message: "標籤檔僅支援 .txt / .names", + } + } + + // LimitReader 是 header.Size 之外的第二道防線:Content-Length 可以造假, + // 實際串流長度才是真的。多讀 1 byte 用來偵測「宣稱小、其實大」。 + data, readErr := io.ReadAll(io.LimitReader(file, labelfile.MaxFileSize+1)) + if readErr != nil { + return opts, summary, &apiError{ + status: 400, + code: "BAD_REQUEST", + message: "failed to read labelFile: " + readErr.Error(), + } + } + if len(data) > labelfile.MaxFileSize { + return opts, summary, &apiError{ + status: 400, + code: "LABEL_TOO_LARGE", + message: fmt.Sprintf("標籤檔過大,上限為 %d bytes", labelfile.MaxFileSize), + } + } + + result, parseErr := labelfile.Parse(data) + if parseErr != nil { + var pe *labelfile.ParseError + if errors.As(parseErr, &pe) { + return opts, summary, &apiError{ + status: 400, + code: "LABEL_PARSE_ERROR", + message: pe.Error(), + line: pe.Line, + } + } + return opts, summary, &apiError{ + status: 400, + code: "LABEL_PARSE_ERROR", + message: parseErr.Error(), + } + } + + opts.Labels = result.Labels + summary.namedCount = result.LabelCount + return opts, summary, nil +} + +// countNamedLabels 算出實際有名稱的筆數(稀疏補洞的空字串不計)。 +func countNamedLabels(labels []string) int { + n := 0 + for _, l := range labels { + if strings.TrimSpace(l) != "" { + n++ + } + } + return n +} + func (h *DeviceHandler) StartInference(c *gin.Context) { id := c.Param("id") resultCh := make(chan *driver.InferenceResult, 10) diff --git a/local-tool/server/internal/api/handlers/device_inference_options_test.go b/local-tool/server/internal/api/handlers/device_inference_options_test.go new file mode 100644 index 0000000..d48547a --- /dev/null +++ b/local-tool/server/internal/api/handlers/device_inference_options_test.go @@ -0,0 +1,500 @@ +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 +} diff --git a/local-tool/server/internal/api/router.go b/local-tool/server/internal/api/router.go index fcaa4ac..9f138af 100644 --- a/local-tool/server/internal/api/router.go +++ b/local-tool/server/internal/api/router.go @@ -95,6 +95,9 @@ func NewRouter( api.POST("/devices/:id/flash", deviceHandler.FlashDevice) api.POST("/devices/:id/inference/start", deviceHandler.StartInference) api.POST("/devices/:id/inference/stop", deviceHandler.StopInference) + // M4:推論期動態切換解析方式 / label 表(不重燒 model)。 + // 設定不持久化 —— 只作用於當前 bridge session。 + api.POST("/devices/:id/inference/options", deviceHandler.SetInferenceOptions) // Firmware (M9-3、A 階段) // upgrade endpoint 走 202 + WebSocket room "firmware:" 推進度。 diff --git a/local-tool/server/internal/driver/kneron/set_inference_options_test.go b/local-tool/server/internal/driver/kneron/set_inference_options_test.go new file mode 100644 index 0000000..068d795 --- /dev/null +++ b/local-tool/server/internal/driver/kneron/set_inference_options_test.go @@ -0,0 +1,180 @@ +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) + } +} diff --git a/local-tool/server/internal/labelfile/parse.go b/local-tool/server/internal/labelfile/parse.go new file mode 100644 index 0000000..3963e1a --- /dev/null +++ b/local-tool/server/internal/labelfile/parse.go @@ -0,0 +1,233 @@ +// Package labelfile 解析使用者上傳的 label 檔(` <名稱>` 每行一筆), +// 產出可直接注入 Python bridge 的密集 []string。 +// +// 為什麼是獨立 package:解析規則全是純函式、與 HTTP / driver / model 儲存都 +// 無關,抽出來才能用大量 table-driven 測試把容錯規則逐條釘死(規則來源見 +// plan-classification-inference.md §3.2,該表已定死、實作不得自行發揮)。 +package labelfile + +import ( + "bufio" + "bytes" + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +const ( + // MaxIndex 是允許的最大 class index(含)。 + // + // 這是本功能最實際的 DoS 面(plan §3.5 / §7 R-7):解析結果要轉成密集 + // []string,一行 `999999999 x` 就會要求配置約 8GB 的 slice header 空間。 + // 4095 對真實模型綽綽有餘(ImageNet 1000 類、COCO 80 類)。 + MaxIndex = 4095 + + // MaxLines 是允許的最大「有效內容行數」(不含空行與註解),與 MaxIndex + // 互為雙保險:MaxIndex 擋單一巨大 index,MaxLines 擋「index 都合法但行數 + // 爆量」。 + // + // 為什麼剛好是 MaxIndex+1 而不是更大的值:index 必須唯一且 <= MaxIndex, + // 所以合法檔案最多就是 MaxIndex+1 行。設得比這更大會讓這道檢查永遠碰不到 + // (重複 index 的檢查會先擋下),變成無效防禦 —— 有一個「看起來有防護但 + // 其實跑不到」的常數,比沒有更糟。 + MaxLines = MaxIndex + 1 + + // MaxFileSize 是允許的檔案大小上限(bytes)。 + // 3 類 label 約 30 bytes;即使 4096 類中文標籤也遠低於 256 KB。 + MaxFileSize = 256 * 1024 +) + +// Result 是一次成功解析的產物。 +type Result struct { + // Labels 是密集陣列:位置 = class index,稀疏處為空字串。 + // + // 為什麼用密集 []string 而非 map(plan §3.2 A1):Model.Labels 與 + // FlashOptions.Labels 都已經是 []string,改成 map 要動 Go struct、TS type、 + // models.json 全部既有 model 與 upload handler。Python 端 _resolve_label + // 對空字串已會 fallback 回 class_N,稀疏語意天然成立。 + Labels []string + + // LabelCount 是「實際有名稱的筆數」(不含補洞用的空字串)。 + LabelCount int + + // MaxIndex 是檔案中出現過的最大 index,等於 len(Labels)-1。 + MaxIndex int +} + +// ParseError 帶行號的解析失敗。整檔拒絕(不做部分接受)—— 靜默略過壞行會讓 +// 使用者拿到看似成功但標註錯位的結果,那比直接失敗糟得多。 +type ParseError struct { + // Line 是 1-based 行號;0 表示錯誤與特定行無關(如空檔、編碼問題)。 + Line int + // Reason 是給使用者看的說明。 + Reason string +} + +func (e *ParseError) Error() string { + if e.Line > 0 { + return fmt.Sprintf("第 %d 行:%s", e.Line, e.Reason) + } + return e.Reason +} + +// utf8BOM 是 UTF-8 位元組順序標記。Windows 記事本另存 UTF-8 會加它, +// 不 strip 的話第一行的 index token 會帶著 BOM 位元組而解析失敗。 +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// Parse 解析 label 檔內容。 +// +// 容錯規則完全依照 plan §3.2 的表: +// +// 空行 / 純空白行 → 略過 +// `#` 開頭 → 註解、略過 +// 行尾 \r(CRLF) → trim +// 名稱含空白 → 只 split 第一個空白,其餘全算名稱 +// index 非整數 / 負數 → 整檔拒絕 + 行號 +// index 重複 → 整檔拒絕 + 行號 +// index 不連續 / 不從 0 開始 → 接受,缺的位置補空字串 +// 空檔 / 全空行 → 拒絕 +// 非 UTF-8 → 拒絕(BOM 先 strip) +// 只有 index 沒名稱 → 拒絕 + 行號 +func Parse(data []byte) (*Result, error) { + if len(data) > MaxFileSize { + return nil, &ParseError{ + Reason: fmt.Sprintf("檔案過大(%d bytes),上限為 %d bytes", len(data), MaxFileSize), + } + } + + data = bytes.TrimPrefix(data, utf8BOM) + + if !utf8.Valid(data) { + return nil, &ParseError{ + Reason: "檔案不是有效的 UTF-8 編碼,請改存成 UTF-8 後再上傳", + } + } + + // byIndex 保留原始的稀疏語意,最後才展開成密集陣列 —— 先展開的話, + // 「index 重複」與「index 不連續補洞」兩種情況會分不出來。 + byIndex := make(map[int]string) + maxIndex := -1 + contentLines := 0 + + scanner := bufio.NewScanner(bytes.NewReader(data)) + // 單行上限放寬到 64KB:預設 bufio 上限也是 64KB,但預設 buffer 只有 4KB + // 起跳、長行會回 bufio.ErrTooLong 而不是我們自己的錯誤訊息。 + scanner.Buffer(make([]byte, 0, 4096), 64*1024) + + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimRight(scanner.Text(), "\r") + + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + continue + } + + contentLines++ + if contentLines > MaxLines { + return nil, &ParseError{ + Line: lineNo, + Reason: fmt.Sprintf("標籤行數超過上限 %d", MaxLines), + } + } + + idx, name, err := parseLine(trimmed) + if err != nil { + err.Line = lineNo + return nil, err + } + + if _, dup := byIndex[idx]; dup { + return nil, &ParseError{ + Line: lineNo, + Reason: fmt.Sprintf("index %d 重複出現", idx), + } + } + + byIndex[idx] = name + if idx > maxIndex { + maxIndex = idx + } + } + if err := scanner.Err(); err != nil { + return nil, &ParseError{Reason: fmt.Sprintf("讀取檔案失敗:%v", err)} + } + + if len(byIndex) == 0 { + return nil, &ParseError{Reason: "標籤檔沒有任何有效內容"} + } + + labels := make([]string, maxIndex+1) + for idx, name := range byIndex { + labels[idx] = name + } + + return &Result{ + Labels: labels, + LabelCount: len(byIndex), + MaxIndex: maxIndex, + }, nil +} + +// parseLine 解析單行 ` <名稱>`。回傳的 ParseError 不帶 Line,由呼叫端補。 +func parseLine(line string) (int, string, *ParseError) { + // 只切第一個空白:`0 traffic light` 必須解析成 {0: "traffic light"}。 + // strings.Fields 會把名稱也切碎,所以刻意用 IndexFunc 自己找分界。 + sep := strings.IndexFunc(line, unicode.IsSpace) + if sep < 0 { + return 0, "", &ParseError{ + Reason: fmt.Sprintf("缺少標籤名稱(只有 %q),格式應為 ` <名稱>`", line), + } + } + + idxToken := line[:sep] + name := strings.TrimSpace(line[sep:]) + if name == "" { + return 0, "", &ParseError{ + Reason: fmt.Sprintf("index %s 後面缺少標籤名稱", idxToken), + } + } + + idx, err := strconv.Atoi(idxToken) + if err != nil { + return 0, "", &ParseError{ + Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken), + } + } + if idx < 0 { + return 0, "", &ParseError{ + Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken), + } + } + if idx > MaxIndex { + return 0, "", &ParseError{ + Reason: fmt.Sprintf("index %d 超過上限 %d", idx, MaxIndex), + } + } + + if bad, ok := findControlChar(name); ok { + return 0, "", &ParseError{ + Reason: fmt.Sprintf("標籤名稱含有不允許的控制字元(U+%04X)", bad), + } + } + + return idx, name, nil +} + +// findControlChar 找出名稱中的控制字元。標籤會直接被渲染到前端 DOM 與 canvas, +// 控制字元(含 U+202E 這類 bidi override)會造成顯示錯亂,一律拒絕。 +// Tab 已在 TrimSpace / 分隔判斷階段處理掉,這裡不需特別放行。 +func findControlChar(name string) (rune, bool) { + for _, r := range name { + if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) { + return r, true + } + } + return 0, false +} diff --git a/local-tool/server/internal/labelfile/parse_test.go b/local-tool/server/internal/labelfile/parse_test.go new file mode 100644 index 0000000..911fa92 --- /dev/null +++ b/local-tool/server/internal/labelfile/parse_test.go @@ -0,0 +1,423 @@ +package labelfile + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// asParseError 取出 *ParseError;不是的話直接讓測試失敗。 +func asParseError(t *testing.T, err error) *ParseError { + t.Helper() + if err == nil { + t.Fatalf("expected error, got nil") + } + var pe *ParseError + if !errors.As(err, &pe) { + t.Fatalf("expected *ParseError, got %T (%v)", err, err) + } + return pe +} + +// ── Happy path ─────────────────────────────────────────────────────── + +func TestParse_RealWorldFile(t *testing.T) { + // 使用者提供的 labels.txt(剪刀/石頭/布)逐位元組相同的內容。 + got, err := Parse([]byte("0 剪刀\n1 石頭\n2 布\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"剪刀", "石頭", "布"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v", got.Labels, want) + } + if got.LabelCount != 3 { + t.Errorf("LabelCount = %d, want 3", got.LabelCount) + } + if got.MaxIndex != 2 { + t.Errorf("MaxIndex = %d, want 2", got.MaxIndex) + } +} + +func TestParse_NoTrailingNewline(t *testing.T) { + got, err := Parse([]byte("0 a\n1 b")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if !equalStrings(got.Labels, []string{"a", "b"}) { + t.Errorf("Labels = %v", got.Labels) + } +} + +// ── plan §3.2 容錯表:逐列 ──────────────────────────────────────────── + +// 表列 1:空行 → 略過 +func TestParse_TableRow_BlankLinesSkipped(t *testing.T) { + got, err := Parse([]byte("0 a\n\n\n1 b\n\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if !equalStrings(got.Labels, []string{"a", "b"}) { + t.Errorf("Labels = %v, want [a b]", got.Labels) + } +} + +// 表列 2:只有空白的行 → 略過 +func TestParse_TableRow_WhitespaceOnlyLinesSkipped(t *testing.T) { + got, err := Parse([]byte("0 a\n \n\t\t\n1 b\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if !equalStrings(got.Labels, []string{"a", "b"}) { + t.Errorf("Labels = %v, want [a b]", got.Labels) + } +} + +// 表列 3:以 # 開頭 → 視為註解、略過 +func TestParse_TableRow_CommentLinesSkipped(t *testing.T) { + got, err := Parse([]byte("# 這是註解\n0 a\n # 縮排註解也算\n1 b\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if !equalStrings(got.Labels, []string{"a", "b"}) { + t.Errorf("Labels = %v, want [a b]", got.Labels) + } +} + +// 表列 3 反例:`#` 出現在名稱中間不是註解 +func TestParse_TableRow_HashInsideNameIsNotComment(t *testing.T) { + got, err := Parse([]byte("0 C#\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + if !equalStrings(got.Labels, []string{"C#"}) { + t.Errorf("Labels = %v, want [C#]", got.Labels) + } +} + +// 表列 4:行尾 \r(CRLF)→ trim 掉 +func TestParse_TableRow_CRLFTrimmed(t *testing.T) { + got, err := Parse([]byte("0 剪刀\r\n1 石頭\r\n2 布\r\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"剪刀", "石頭", "布"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v (CR 未被 trim?)", got.Labels, want) + } + // 逐字元確認沒有殘留 \r —— equalStrings 若有 bug 可能漏掉。 + for i, l := range got.Labels { + if strings.ContainsRune(l, '\r') { + t.Errorf("Labels[%d] = %q 仍含 CR", i, l) + } + } +} + +// 表列 5:名稱含空白 → 只 split 第一個空白、其餘全算名稱 +func TestParse_TableRow_NameWithSpacesKeptWhole(t *testing.T) { + got, err := Parse([]byte("0 traffic light\n1 stop sign here\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"traffic light", "stop sign here"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v", got.Labels, want) + } +} + +// 表列 5 變體:index 與名稱之間多個空白 / tab +func TestParse_TableRow_MultipleSeparatorWhitespaceCollapsed(t *testing.T) { + got, err := Parse([]byte("0\t\t剪刀\n1 石 頭\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"剪刀", "石 頭"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v", got.Labels, want) + } +} + +// 表列 6:index 不是整數 → 整檔拒絕 + 行號 +func TestParse_TableRow_NonIntegerIndexRejected(t *testing.T) { + cases := []struct { + name string + content string + wantLine int + }{ + {"字母", "0 a\n1 b\nabc c\n", 3}, + {"小數", "0 a\n1.5 b\n", 2}, + {"十六進位", "0x1 a\n", 1}, + {"含前導加號以外的雜訊", "0 a\n1_ b\n", 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse([]byte(tc.content)) + pe := asParseError(t, err) + if pe.Line != tc.wantLine { + t.Errorf("Line = %d, want %d (err=%v)", pe.Line, tc.wantLine, pe) + } + if !strings.Contains(pe.Reason, "非負整數") { + t.Errorf("Reason = %q, 應說明 index 必須為非負整數", pe.Reason) + } + }) + } +} + +// 表列 7:index 為負數 → 整檔拒絕 +func TestParse_TableRow_NegativeIndexRejected(t *testing.T) { + _, err := Parse([]byte("0 a\n-1 b\n")) + pe := asParseError(t, err) + if pe.Line != 2 { + t.Errorf("Line = %d, want 2", pe.Line) + } +} + +// 表列 8:index 重複 → 整檔拒絕 + 指出重複的 index +func TestParse_TableRow_DuplicateIndexRejected(t *testing.T) { + _, err := Parse([]byte("0 a\n1 b\n1 c\n")) + pe := asParseError(t, err) + if pe.Line != 3 { + t.Errorf("Line = %d, want 3", pe.Line) + } + if !strings.Contains(pe.Reason, "1") || !strings.Contains(pe.Reason, "重複") { + t.Errorf("Reason = %q, 應指出重複的 index", pe.Reason) + } +} + +// 表列 8 變體:重複且名稱相同也一樣拒絕(不做「反正一樣就放行」的體貼) +func TestParse_TableRow_DuplicateIndexSameNameStillRejected(t *testing.T) { + _, err := Parse([]byte("0 a\n0 a\n")) + asParseError(t, err) +} + +// 表列 9:index 不連續 → 接受,缺的位置補空字串 +func TestParse_TableRow_SparseIndexAccepted(t *testing.T) { + got, err := Parse([]byte("0 a\n1 b\n3 d\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"a", "b", "", "d"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v", got.Labels, want) + } + if got.LabelCount != 3 { + t.Errorf("LabelCount = %d, want 3(不含補洞的空字串)", got.LabelCount) + } + if got.MaxIndex != 3 { + t.Errorf("MaxIndex = %d, want 3", got.MaxIndex) + } +} + +// 表列 10:index 不從 0 開始 → 接受 +func TestParse_TableRow_IndexNotStartingAtZeroAccepted(t *testing.T) { + got, err := Parse([]byte("5 e\n6 f\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"", "", "", "", "", "e", "f"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v", got.Labels, want) + } + if got.LabelCount != 2 { + t.Errorf("LabelCount = %d, want 2", got.LabelCount) + } +} + +// 表列 10 變體:亂序也接受(index 決定位置、不是出現順序) +func TestParse_TableRow_OutOfOrderIndexAccepted(t *testing.T) { + got, err := Parse([]byte("2 布\n0 剪刀\n1 石頭\n")) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + want := []string{"剪刀", "石頭", "布"} + if !equalStrings(got.Labels, want) { + t.Errorf("Labels = %v, want %v", got.Labels, want) + } +} + +// 表列 11:檔案為空 / 全是空行 → 拒絕 +func TestParse_TableRow_EmptyFileRejected(t *testing.T) { + cases := map[string]string{ + "完全空": "", + "只有換行": "\n\n\n", + "只有空白": " \n\t\n", + "只有註解": "# nothing here\n# still nothing\n", + "只有 BOM": "\xEF\xBB\xBF", + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + _, err := Parse([]byte(content)) + pe := asParseError(t, err) + if pe.Line != 0 { + t.Errorf("Line = %d, want 0(與特定行無關)", pe.Line) + } + if !strings.Contains(pe.Reason, "有效內容") { + t.Errorf("Reason = %q", pe.Reason) + } + }) + } +} + +// 表列 12:編碼非 UTF-8 → 拒絕;BOM 要 strip +func TestParse_TableRow_InvalidUTF8Rejected(t *testing.T) { + // Big5 的「剪刀」= 0xB0 0x45 0xA4 0x4D,在 UTF-8 下是非法序列。 + content := append([]byte("0 "), 0xB0, 0x45, 0xA4, 0x4D, '\n') + _, err := Parse(content) + pe := asParseError(t, err) + if !strings.Contains(pe.Reason, "UTF-8") { + t.Errorf("Reason = %q, 應提示改存 UTF-8", pe.Reason) + } +} + +func TestParse_TableRow_UTF8BOMStripped(t *testing.T) { + content := append([]byte{0xEF, 0xBB, 0xBF}, []byte("0 剪刀\n1 石頭\n")...) + got, err := Parse(content) + if err != nil { + t.Fatalf("Parse error: %v(BOM 未被 strip?)", err) + } + if !equalStrings(got.Labels, []string{"剪刀", "石頭"}) { + t.Errorf("Labels = %v", got.Labels) + } +} + +// 表列 13:只有 index 沒有名稱 → 拒絕 + 行號 +func TestParse_TableRow_IndexWithoutNameRejected(t *testing.T) { + cases := []struct { + name string + content string + wantLine int + }{ + {"純數字行", "0 a\n1\n", 2}, + {"數字後只有空白", "0 a\n1 \n", 2}, // TrimSpace 後變 "1"、與純數字行同路 + {"第一行就缺", "0\n", 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse([]byte(tc.content)) + pe := asParseError(t, err) + if pe.Line != tc.wantLine { + t.Errorf("Line = %d, want %d (err=%v)", pe.Line, tc.wantLine, pe) + } + if !strings.Contains(pe.Reason, "名稱") { + t.Errorf("Reason = %q, 應說明缺少名稱", pe.Reason) + } + }) + } +} + +// ── plan §3.5 安全上限(S-2 / R-7)─────────────────────────────────── + +func TestParse_MaxIndexEnforced(t *testing.T) { + t.Run("剛好在上限內", func(t *testing.T) { + got, err := Parse([]byte(fmt.Sprintf("%d ok\n", MaxIndex))) + if err != nil { + t.Fatalf("index %d 應被接受,卻拒絕:%v", MaxIndex, err) + } + if len(got.Labels) != MaxIndex+1 { + t.Errorf("len(Labels) = %d, want %d", len(got.Labels), MaxIndex+1) + } + }) + + t.Run("超過上限一格就拒絕", func(t *testing.T) { + _, err := Parse([]byte(fmt.Sprintf("%d boom\n", MaxIndex+1))) + pe := asParseError(t, err) + if pe.Line != 1 { + t.Errorf("Line = %d, want 1", pe.Line) + } + if !strings.Contains(pe.Reason, "上限") { + t.Errorf("Reason = %q, 應說明超過上限", pe.Reason) + } + }) + + t.Run("巨大 index 不會嘗試配置記憶體", func(t *testing.T) { + // 若上限檢查失效,這行會嘗試 make([]string, 1e9),測試會 OOM 而非失敗。 + _, err := Parse([]byte("999999999 boom\n")) + asParseError(t, err) + }) +} + +func TestParse_MaxFileSizeEnforced(t *testing.T) { + big := make([]byte, MaxFileSize+1) + for i := range big { + big[i] = 'a' + } + _, err := Parse(big) + pe := asParseError(t, err) + if !strings.Contains(pe.Reason, "過大") { + t.Errorf("Reason = %q, 應說明檔案過大", pe.Reason) + } +} + +func TestParse_MaxLinesEnforced(t *testing.T) { + // index 唯一且 <= MaxIndex 的合法檔案最多 MaxIndex+1 行,所以要觸發行數 + // 上限一定得帶重複 index —— 此測試同時釘住「行數檢查排在重複檢查之前」。 + // 若哪天有人把行數檢查移到 parseLine / dup 檢查之後,這裡會看到「重複」 + // 而非「行數」,測試失敗。 + var sb strings.Builder + for i := 0; i <= MaxLines; i++ { + fmt.Fprintf(&sb, "%d l%d\n", i%(MaxIndex+1), i) + } + _, err := Parse([]byte(sb.String())) + pe := asParseError(t, err) + if !strings.Contains(pe.Reason, "行數") { + t.Errorf("Reason = %q, 應說明行數超過上限(行數檢查是否被移到重複檢查之後?)", pe.Reason) + } +} + +// MaxLines 必須 <= MaxIndex+1,否則行數檢查永遠碰不到(重複 index 會先擋)。 +// 這條把「無效防禦」的可能性從常數層面就釘死。 +func TestParse_MaxLinesIsReachable(t *testing.T) { + if MaxLines > MaxIndex+1 { + t.Fatalf("MaxLines(%d) > MaxIndex+1(%d):行數檢查永遠不可能觸發", + MaxLines, MaxIndex+1) + } +} + +func TestParse_ControlCharactersInNameRejected(t *testing.T) { + cases := map[string]string{ + "NUL": "0 a\x00b\n", + "ESC": "0 a\x1bb\n", + "BiDi override": "0 a‮b\n", + "BiDi isolate": "0 a⁦b\n", + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + _, err := Parse([]byte(content)) + pe := asParseError(t, err) + if !strings.Contains(pe.Reason, "控制字元") { + t.Errorf("Reason = %q, 應說明控制字元", pe.Reason) + } + }) + } +} + +// ── 錯誤訊息品質 ───────────────────────────────────────────────────── + +func TestParseError_MessageIncludesLineNumber(t *testing.T) { + pe := &ParseError{Line: 5, Reason: "index 必須為非負整數,收到 \"abc\""} + if !strings.Contains(pe.Error(), "第 5 行") { + t.Errorf("Error() = %q, 應含行號", pe.Error()) + } +} + +func TestParseError_NoLineOmitsPrefix(t *testing.T) { + pe := &ParseError{Reason: "標籤檔沒有任何有效內容"} + if strings.Contains(pe.Error(), "行") { + t.Errorf("Error() = %q, 無行號時不應帶行號前綴", pe.Error()) + } +} + +// ── helper ─────────────────────────────────────────────────────────── + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}