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 }