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

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

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

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

424 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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行尾 \rCRLF→ 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)
}
}
// 表列 6index 不是整數 → 整檔拒絕 + 行號
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)
}
})
}
}
// 表列 7index 為負數 → 整檔拒絕
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)
}
}
// 表列 8index 重複 → 整檔拒絕 + 指出重複的 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)
}
// 表列 9index 不連續 → 接受,缺的位置補空字串
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)
}
}
// 表列 10index 不從 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: %vBOM 未被 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
}