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, '�') { 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, '�') { 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, '�') { 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)) } }