新增 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>
424 lines
13 KiB
Go
424 lines
13 KiB
Go
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
|
||
}
|