260 lines
10 KiB
Go
260 lines
10 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"unicode/utf8"
|
||
|
||
"golang.org/x/text/encoding/japanese"
|
||
"golang.org/x/text/encoding/simplifiedchinese"
|
||
xunicode "golang.org/x/text/encoding/unicode"
|
||
"golang.org/x/text/transform"
|
||
)
|
||
|
||
// A realistic zh chapter: Han + CJK punctuation + fullwidth quotes + a stray Latin name + digits.
|
||
const zhChapter = "第一章 天空之陨\n\n方源睁开眼睛,“这是哪里?”他喃喃自语。三万年后,Gu 之力遍布天地。"
|
||
|
||
func gb18030Bytes(t *testing.T, s string) []byte {
|
||
t.Helper()
|
||
b, _, err := transform.Bytes(simplifiedchinese.GB18030.NewEncoder(), []byte(s))
|
||
if err != nil {
|
||
t.Fatalf("encode gb18030: %v", err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
func utf16Bytes(t *testing.T, s string, endian xunicode.Endianness) []byte {
|
||
t.Helper()
|
||
b, _, err := transform.Bytes(xunicode.UTF16(endian, xunicode.UseBOM).NewEncoder(), []byte(s))
|
||
if err != nil {
|
||
t.Fatalf("encode utf16: %v", err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
func TestDecodeSourceAutoGB18030(t *testing.T) {
|
||
raw := gb18030Bytes(t, zhChapter)
|
||
got, err := decodeSourceBytes(raw, "auto", "zh")
|
||
if err != nil {
|
||
t.Fatalf("auto-detect of a GB18030 chapter failed: %v", err)
|
||
}
|
||
if got != zhChapter {
|
||
t.Fatalf("GB18030 round-trip mismatch:\n got %q\nwant %q", got, zhChapter)
|
||
}
|
||
// The whole point: a GB18030 file is NOT valid UTF-8, so the naive string(raw) would be mojibake.
|
||
if strings.ContainsRune(got, '<27>') {
|
||
t.Error("decoded text contains U+FFFD — the lossless invariant is broken")
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceAutoUTF8NoBOM(t *testing.T) {
|
||
got, err := decodeSourceBytes([]byte(zhChapter), "auto", "zh")
|
||
if err != nil {
|
||
t.Fatalf("valid UTF-8 must pass through: %v", err)
|
||
}
|
||
if got != zhChapter {
|
||
t.Fatalf("UTF-8 passthrough mismatch: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceAutoUTF8BOM(t *testing.T) {
|
||
raw := append(append([]byte(nil), 0xEF, 0xBB, 0xBF), []byte(zhChapter)...)
|
||
got, err := decodeSourceBytes(raw, "auto", "zh")
|
||
if err != nil {
|
||
t.Fatalf("UTF-8 BOM file must decode: %v", err)
|
||
}
|
||
// The BOM byte may survive as U+FEFF here; NormalizeSource strips it downstream. What matters is
|
||
// the body decoded correctly and no mojibake appeared.
|
||
if !strings.Contains(got, "第一章 天空之陨") {
|
||
t.Fatalf("UTF-8 BOM body mis-decoded: %q", got)
|
||
}
|
||
if strings.ContainsRune(got, '<27>') {
|
||
t.Error("U+FFFD after BOM decode")
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceAutoUTF16(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
endian xunicode.Endianness
|
||
}{{"LE", xunicode.LittleEndian}, {"BE", xunicode.BigEndian}} {
|
||
raw := utf16Bytes(t, zhChapter, tc.endian)
|
||
got, err := decodeSourceBytes(raw, "auto", "zh")
|
||
if err != nil {
|
||
t.Fatalf("UTF-16 %s must decode: %v", tc.name, err)
|
||
}
|
||
if !strings.Contains(got, "方源睁开眼睛") {
|
||
t.Fatalf("UTF-16 %s mis-decoded: %q", tc.name, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceAutoRejectsGarbage(t *testing.T) {
|
||
// Random high bytes: not valid UTF-8, GB18030 yields U+FFFD → fail loud, not silent junk.
|
||
raw := []byte{0x00, 0x01, 0xFF, 0xFE, 0x80, 0x81, 0x00, 0x9F, 0xC0}
|
||
if got, err := decodeSourceBytes(raw, "auto", "zh"); err == nil {
|
||
t.Fatalf("garbage bytes must fail loud, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceAutoRejectsLatin1(t *testing.T) {
|
||
// Latin-1 "Café déjà vu — naïve" is invalid UTF-8; GB18030 decodes it into mojibake that here
|
||
// contains U+FFFD, so it is rejected by the strict U+FFFD check (NOT the Han-density guard — see
|
||
// TestPlausibleCJKGuard for direct density coverage). Either way it must fail loud.
|
||
raw := []byte{0x43, 0x61, 0x66, 0xE9, 0x20, 0x64, 0xE9, 0x6A, 0xE0, 0x20, 0x76, 0x75, 0x20, 0x2D, 0x20, 0x6E, 0x61, 0xEF, 0x76, 0x65}
|
||
if got, err := decodeSourceBytes(raw, "auto", "zh"); err == nil {
|
||
t.Fatalf("Latin-1 mojibake must fail loud, got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestPlausibleCJKGuard directly locks the Han-density guard (self-review: it had zero coverage —
|
||
// every mojibake fixture was caught earlier by the U+FFFD check). A clean-decoding low-Han string
|
||
// must be rejected; a Han-dense string must pass.
|
||
func TestPlausibleCJKGuard(t *testing.T) {
|
||
if err := plausibleCJK("Hello world, ordinary Latin prose with one 一 ideograph in it."); err == nil {
|
||
t.Error("low-Han text must fail the plausibility guard")
|
||
}
|
||
if err := plausibleCJK("方源睁开眼睛,这是哪里?天地无穷,万物玄妙。"); err != nil {
|
||
t.Errorf("Han-dense Chinese must pass the plausibility guard: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestDecodeSourceRejectsShiftJISForJa is the self-review CRITICAL: a Japanese Shift-JIS source must
|
||
// NOT be silently accepted as GB18030 mojibake. With source_lang=ja the GB18030 auto-probe is off,
|
||
// so it fails loud (the operator converts to UTF-8). Shift-JIS is invalid UTF-8, so the ladder does
|
||
// reach the (now gated) GB18030 step.
|
||
func TestDecodeSourceRejectsShiftJISForJa(t *testing.T) {
|
||
ja := "彼女は静かに窓の外を眺めていた。"
|
||
sj, _, err := transform.Bytes(japanese.ShiftJIS.NewEncoder(), []byte(ja))
|
||
if err != nil {
|
||
t.Fatalf("encode shift-jis: %v", err)
|
||
}
|
||
if utf8.Valid(sj) {
|
||
t.Fatal("setup: shift-jis bytes unexpectedly valid UTF-8")
|
||
}
|
||
if got, derr := decodeSourceBytes(sj, "auto", "ja"); derr == nil {
|
||
t.Fatalf("a ja Shift-JIS source must fail loud under auto, got %q", got)
|
||
}
|
||
}
|
||
|
||
// TestDecodeSourceRejectsUTF16NoBOM is the self-review major: ASCII text saved as UTF-16 without a
|
||
// BOM is valid UTF-8 with interleaved NUL — the NUL guard must reject it, not pass silent corruption.
|
||
func TestDecodeSourceRejectsUTF16NoBOM(t *testing.T) {
|
||
enc := xunicode.UTF16(xunicode.LittleEndian, xunicode.IgnoreBOM).NewEncoder()
|
||
raw, _, err := transform.Bytes(enc, []byte("Hello world, a plain English sentence."))
|
||
if err != nil {
|
||
t.Fatalf("encode utf16-no-bom: %v", err)
|
||
}
|
||
if got, derr := decodeSourceBytes(raw, "auto", "en"); derr == nil {
|
||
t.Fatalf("UTF-16 without a BOM must fail loud (NUL guard), got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceExplicitGB18030(t *testing.T) {
|
||
raw := gb18030Bytes(t, zhChapter)
|
||
got, err := decodeSourceBytes(raw, "gb18030", "zh")
|
||
if err != nil {
|
||
t.Fatalf("explicit gb18030 must decode: %v", err)
|
||
}
|
||
if got != zhChapter {
|
||
t.Fatalf("explicit gb18030 mismatch: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceExplicitUTF8OnGBFailsLoud(t *testing.T) {
|
||
raw := gb18030Bytes(t, zhChapter) // not valid UTF-8
|
||
if _, err := decodeSourceBytes(raw, "utf8", "zh"); err == nil {
|
||
t.Fatal("declaring utf8 on GB18030 bytes must fail loud, not mojibake")
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceExplicitGBOnCorruptFailsLoud(t *testing.T) {
|
||
// A valid GB18030 chapter truncated mid-multibyte → U+FFFD → strict decode fails loud.
|
||
raw := gb18030Bytes(t, zhChapter)
|
||
if got, err := decodeSourceBytes(raw[:len(raw)-1], "gb18030", "zh"); err == nil {
|
||
t.Fatalf("truncated GB18030 must fail loud (U+FFFD), got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDecodeSourceUnknownEncoding(t *testing.T) {
|
||
if _, err := decodeSourceBytes([]byte("hi"), "shift-jis", ""); err == nil {
|
||
t.Fatal("an unsupported explicit encoding must be rejected")
|
||
}
|
||
}
|
||
|
||
// TestIngestTXTGB18030EndToEnd exercises the whole txt path: a GB18030 file with a \f chapter break
|
||
// decodes, splits, and normalizes — the D18 acceptance-book ingest case (蛊真人 is GB18030).
|
||
func TestIngestTXTGB18030EndToEnd(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := filepath.Join(dir, "book.txt")
|
||
twoChapters := zhChapter + "\f" + "第二章 洞窟\n\n他走进了洞窟深处。"
|
||
if err := os.WriteFile(p, gb18030Bytes(t, twoChapters), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
doc, err := IngestEncoded(p, "gb18030", "zh")
|
||
if err != nil {
|
||
t.Fatalf("ingest gb18030 txt: %v", err)
|
||
}
|
||
if len(doc.Chapters) != 2 {
|
||
t.Fatalf("want 2 chapters (split on \\f), got %d", len(doc.Chapters))
|
||
}
|
||
if !strings.Contains(doc.Chapters[0], "方源") || !strings.Contains(doc.Chapters[1], "洞窟") {
|
||
t.Fatalf("chapters mis-decoded: %q | %q", doc.Chapters[0], doc.Chapters[1])
|
||
}
|
||
for _, ch := range doc.Chapters {
|
||
if strings.ContainsRune(ch, '<27>') {
|
||
t.Errorf("U+FFFD in a normalized chapter: %q", ch)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSplitTextChapters covers the CJK chapter-header splitting (D18: 蛊真人 uses «第N节» headers).
|
||
func TestSplitTextChapters(t *testing.T) {
|
||
jie := "第一节:начало\n方源开口。\n第二节:середина\n他走了。\n第三节:конец\n完。"
|
||
got := splitTextChapters(jie)
|
||
if len(got) != 3 {
|
||
t.Fatalf("第N节 split: want 3 chapters, got %d: %q", len(got), got)
|
||
}
|
||
if !strings.HasPrefix(got[0], "第一节") || !strings.HasPrefix(got[1], "第二节") {
|
||
t.Errorf("headers must start their chapter: %q", got)
|
||
}
|
||
|
||
zhang := "第1章 первая\nтекст\n第2章 вторая\nтекст"
|
||
if got := splitTextChapters(zhang); len(got) != 2 {
|
||
t.Fatalf("第N章 (arabic) split: want 2, got %d", len(got))
|
||
}
|
||
|
||
// Preamble before the first header merges into chapter 1 (第一节 == chapter 1).
|
||
withPreamble := "书名:Тест\n作者:Автор\n第一节:глава\nтело первой главы\n第二节:вторая\nтело"
|
||
got = splitTextChapters(withPreamble)
|
||
if len(got) != 2 {
|
||
t.Fatalf("preamble merge: want 2 chapters, got %d: %q", len(got), got)
|
||
}
|
||
if !strings.Contains(got[0], "书名") || !strings.Contains(got[0], "第一节") {
|
||
t.Errorf("preamble must merge into chapter 1: %q", got[0])
|
||
}
|
||
|
||
// No headers → a single chapter (backward compat).
|
||
if got := splitTextChapters("Просто текст без глав.\nВторая строка."); len(got) != 1 {
|
||
t.Fatalf("no-header text must be one chapter, got %d", len(got))
|
||
}
|
||
|
||
// A long prose line that merely opens with «第三章…» is NOT a header (length guard).
|
||
prose := "第三章的内容非常精彩,讲述了一个漫长而复杂的故事,充满了各种各样的细节和转折点情节。"
|
||
if got := splitTextChapters(prose); len(got) != 1 {
|
||
t.Fatalf("prose starting with 第三章 must not split, got %d", len(got))
|
||
}
|
||
|
||
// Self-review major: 回 is a measure word, so «第一回见面…» (a content glyph glued after the unit)
|
||
// must NOT split; a real «第N回:» header (separator after the unit) does.
|
||
huiProse := "第一回见面时他就心动了。\n第二回重逢依然如此。"
|
||
if got := splitTextChapters(huiProse); len(got) != 1 {
|
||
t.Fatalf("回 measure-word prose must not split (separator guard), got %d: %q", len(got), got)
|
||
}
|
||
huiHeader := "第一回:初见\nтекст первой.\n第二回:重逢\nтекст второй."
|
||
if got := splitTextChapters(huiHeader); len(got) != 2 {
|
||
t.Fatalf("第N回: headers must split, got %d", len(got))
|
||
}
|
||
}
|