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>
This commit is contained in:
parent
ddd1aae5d1
commit
4583406efa
@ -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 檔案(`<index> <名稱>`)
|
||||
//
|
||||
// 刻意不做任何持久化:使用者明確要求「不用記,每次現場傳」。設定只存在於
|
||||
// 當前 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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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:<id>" 推進度。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
233
local-tool/server/internal/labelfile/parse.go
Normal file
233
local-tool/server/internal/labelfile/parse.go
Normal file
@ -0,0 +1,233 @@
|
||||
// Package labelfile 解析使用者上傳的 label 檔(`<index> <名稱>` 每行一筆),
|
||||
// 產出可直接注入 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 解析單行 `<index> <名稱>`。回傳的 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),格式應為 `<index> <名稱>`", 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
|
||||
}
|
||||
423
local-tool/server/internal/labelfile/parse_test.go
Normal file
423
local-tool/server/internal/labelfile/parse_test.go
Normal file
@ -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 ab\n",
|
||||
"BiDi isolate": "0 ab\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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user