package documents import ( "errors" "strings" "testing" "unicode/utf8" ) // 这些测试钉住抽取器的对外事实(design/24 §4.1/§4.3):纯文本无损、CSV/TSV 带 // 行数/列名摘要、超预算**诚实截断**、非法 UTF-8 不 panic 也不报废、Office 与 // 二进制**诚实拒绝**(哨兵错误,用 errors.Is 判,绝不按错误文本匹配)。PDF 的 // 文本层抽取另有 extract_pdf_test.go。 // csvName / tsvName 是合法的 S1 存储名形态(32 位十六进制 + 白名单扩展名), // 使抽取器之外的行为(sidecar 键、路径校验)与真实上传同形。 const ( csvName = "0123456789abcdef0123456789abcdef.csv" tsvName = "0123456789abcdef0123456789abcdef.tsv" txtName = "0123456789abcdef0123456789abcdef.txt" pdfName = "0123456789abcdef0123456789abcdef.pdf" ) // TestExtract_PlainTextIsLossless 验证纯文本抽取逐字无损、统计与版本随结果返回。 func TestExtract_PlainTextIsLossless(t *testing.T) { t.Parallel() body := "第一行\n第二行\n" res, err := Extract(txtName, "text/plain", []byte(body)) if err != nil { t.Fatalf("Extract: %v", err) } if res.Text != body { t.Fatalf("Text = %q, want %q", res.Text, body) } if res.Chars != utf8.RuneCountInString(body) { t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(body)) } if res.Truncated { t.Fatal("Truncated = true, want false") } if res.Kind != kindText { t.Fatalf("Kind = %q, want %q", res.Kind, kindText) } if res.ExtractorVersion != ExtractorVersion { t.Fatalf("ExtractorVersion = %q, want %q", res.ExtractorVersion, ExtractorVersion) } } // TestExtract_CSVPrependsRowAndColumnSummary 钉住 CSV 特例(design/24 §4.3): // 正文前必须有「行数/列名」摘要,否则模型会拿抽到的片段回答「一共多少行」。 func TestExtract_CSVPrependsRowAndColumnSummary(t *testing.T) { t.Parallel() body := "id,name\n1,alice\n2,bob\n" res, err := Extract(csvName, "text/plain", []byte(body)) if err != nil { t.Fatalf("Extract: %v", err) } if res.Kind != kindCSV { t.Fatalf("Kind = %q, want %q", res.Kind, kindCSV) } summary, rest, ok := strings.Cut(res.Text, "\n") if !ok { t.Fatalf("Text 缺少摘要行: %q", res.Text) } for _, want := range []string{"共 3 行(含表头)", "2 列", "id", "name"} { if !strings.Contains(summary, want) { t.Fatalf("摘要 %q 缺少 %q", summary, want) } } if rest != body { t.Fatalf("摘要之后的正文 = %q, want %q", rest, body) } if res.Chars != utf8.RuneCountInString(res.Text) { t.Fatalf("Chars = %d, want %d", res.Chars, utf8.RuneCountInString(res.Text)) } } // TestExtract_TSVPrependsSummary 验证 TSV 与 CSV 同一条特例路径(制表符切列)。 func TestExtract_TSVPrependsSummary(t *testing.T) { t.Parallel() res, err := Extract(tsvName, "text/plain", []byte("a\tb\n1\t2\n")) if err != nil { t.Fatalf("Extract: %v", err) } if res.Kind != kindTSV { t.Fatalf("Kind = %q, want %q", res.Kind, kindTSV) } if !strings.HasPrefix(res.Text, "[TSV 摘要] 共 2 行(含表头),2 列:a, b\n") { t.Fatalf("Text = %q, want 以 TSV 摘要行开头", res.Text) } } // TestExtract_TruncatesAtSafetyCap 钉住「绝不静默截断」(design/24 §4.3): // 撞上**安全上限**(MaxSidecarBytes,8 MiB 文本)时截到上限并置 Truncated,且截断 // 是 rune 安全的(多字节字符不被切成两半)。 // // 注意它钉的不再是「注入预算」:注入预算由装配期按上下文预算决定(切片 T1 §7.2), // 抽取器只保证 sidecar 里存得下全文、且撞上上限时如实记账。 func TestExtract_TruncatesAtSafetyCap(t *testing.T) { t.Parallel() body := strings.Repeat("你好", MaxSidecarBytes/len("你好")+16) res, err := Extract(txtName, "text/plain", []byte(body)) if err != nil { t.Fatalf("Extract: %v", err) } if !res.Truncated { t.Fatal("Truncated = false, want true") } if len(res.Text) > MaxSidecarBytes { t.Fatalf("截断后正文 %d 字节,超出安全上限 %d", len(res.Text), MaxSidecarBytes) } if res.Chars != utf8.RuneCountInString(res.Text) { t.Fatalf("Chars = %d, want 正文 rune 数 %d", res.Chars, utf8.RuneCountInString(res.Text)) } if !utf8.ValidString(res.Text) { t.Fatal("截断后的正文不是合法 UTF-8") } // 上限内的正文逐字无损:截断只砍尾巴,不改写已有内容。 if !strings.HasPrefix(body, res.Text) { t.Fatal("截断后的正文不是原文的前缀") } } // TestExtract_KeepsFullTextBelowSafetyCap 钉住切片 T1 §7.2 的核心:抽取期**不再** // 按注入预算截断,因此一份远超旧 64K 字符、但仍在安全上限内的文档必须整份存下 —— // 否则 document-read 永远读不到注入预算之外的正文(S4 实测的断裂)。 func TestExtract_KeepsFullTextBelowSafetyCap(t *testing.T) { t.Parallel() body := strings.Repeat("宽", 200*1024) // 20 万字符:远超旧的 64K 注入预算 res, err := Extract(txtName, "text/plain", []byte(body)) if err != nil { t.Fatalf("Extract: %v", err) } if res.Truncated { t.Fatal("Truncated = true, want false(安全上限内的正文不该被截断)") } if res.Chars != 200*1024 || res.Text != body { t.Fatalf("Chars = %d(want %d),正文是否逐字无损 = %v", res.Chars, 200*1024, res.Text == body) } } // TestExtract_SanitizesInvalidUTF8 钉住「非法 UTF-8 要处理,不可 panic」: // 非法字节被替换为 U+FFFD,正文其余部分照常抽出。 func TestExtract_SanitizesInvalidUTF8(t *testing.T) { t.Parallel() data := append([]byte("前缀"), 0xff, 0xfe) data = append(data, []byte("后缀")...) res, err := Extract(txtName, "text/plain", data) if err != nil { t.Fatalf("Extract: %v", err) } if !utf8.ValidString(res.Text) { t.Fatalf("Text 不是合法 UTF-8: %q", res.Text) } if !strings.Contains(res.Text, utf8Replacement) { t.Fatalf("Text = %q, want 含替换字符 %q", res.Text, utf8Replacement) } if !strings.HasPrefix(res.Text, "前缀") || !strings.HasSuffix(res.Text, "后缀") { t.Fatalf("Text = %q, want 保留前后缀", res.Text) } } // TestExtract_EmptyFileIsNotAnError 钉住「空正文」与「读不到」的区别:一份真正 // 空的可读文本不是错误(错误会让上游把「读到了空文件」报成 unreadable)。 func TestExtract_EmptyFileIsNotAnError(t *testing.T) { t.Parallel() res, err := Extract(txtName, "text/plain", nil) if err != nil { t.Fatalf("Extract(empty) = %v, want nil", err) } if res.Text != "" || res.Chars != 0 || res.Truncated { t.Fatalf("Result = %+v, want 空正文且未截断", res) } } // TestExtract_RefusesUnsupportedContent 钉住主路径①的诚实拒绝:二进制内容 // (探测窗口里有 NUL)返回 ErrUnsupported,而不是空正文 + nil 错误。 func TestExtract_RefusesUnsupportedContent(t *testing.T) { t.Parallel() data := []byte{'P', 'K', 0x03, 0x04, 0x00, 0x00, 'x'} if _, err := Extract(txtName, "application/zip", data); !errors.Is(err, ErrUnsupported) { t.Fatalf("Extract(zip) = %v, want ErrUnsupported", err) } if _, err := Extract(txtName, "text/plain", data); !errors.Is(err, ErrUnsupported) { t.Fatalf("Extract(binary) = %v, want ErrUnsupported", err) } } // TestExtract_RefusesNoTextLayer 钉住 Office 与「声明像是 PDF 却不是」的诚实拒绝: // Office 扩展名或嗅探所得 MIME 指向该类格式即 ErrNoTextLayer;而**名字/声明指向 // PDF、字节却不是真 PDF** 时是「说的和做的不一致」,按不支持拒绝 —— 两条都不允许 // 退回按纯文本抽(PDF 文本层本身能抽,用例见 extract_pdf_test.go)。 func TestExtract_RefusesNoTextLayer(t *testing.T) { t.Parallel() if _, err := Extract("report.docx", "text/plain", []byte("docx")); !errors.Is(err, ErrNoTextLayer) { t.Fatalf("Extract(.docx) = %v, want ErrNoTextLayer", err) } if _, err := Extract("sheet.xlsx", "text/plain", []byte("xlsx")); !errors.Is(err, ErrNoTextLayer) { t.Fatalf("Extract(.xlsx) = %v, want ErrNoTextLayer", err) } if _, err := Extract(txtName, "application/msword", []byte("doc")); !errors.Is(err, ErrUnsupported) { t.Fatalf("Extract(word mime) = %v, want ErrUnsupported", err) } // 扩展名看着像 PDF、内容却是纯文本:绝不能按文本抽(那种内容会以「正文」 // 身份进模型上下文),也不能报成「无文本层」。 if _, err := Extract(pdfName, "text/plain", []byte("hi")); !errors.Is(err, ErrUnsupported) { t.Fatalf("Extract(.pdf as text) = %v, want ErrUnsupported", err) } // 扩展名看着像文本、嗅探结果却是 PDF、字节又不是真 PDF:同样拒绝。 if _, err := Extract(txtName, PDFMIME, []byte("hi")); !errors.Is(err, ErrUnsupported) { t.Fatalf("Extract(pdf mime) = %v, want ErrUnsupported", err) } } // TestCountLines 钉住行数口径:空正文 0 行、结尾换行不额外算一行。 func TestCountLines(t *testing.T) { t.Parallel() cases := []struct { in string want int }{ {"", 0}, {"a", 1}, {"a\n", 1}, {"a\nb", 2}, {"a\nb\n", 2}, {"\n", 1}, } for _, c := range cases { if got := countLines(c.in); got != c.want { t.Fatalf("countLines(%q) = %d, want %d", c.in, got, c.want) } } }