package textx import ( "strings" "testing" "unicode/utf8" ) // 中文每字 3 字节、emoji 4 字节:按字节下标切下去,正好切开的位置都要覆盖到。 func TestClipBytesNeverSplitsACharacter(t *testing.T) { s := "删除全部缓存文件🗑️继续" // 混合:中文 3 字节、带变体选择符的 emoji、再中文 cases := []struct { name string s string n int }{ {"切在中文中间", s, 4}, {"切在中文中间2", s, 5}, {"切在 emoji 中间", s, 8}, {"预算为 0", s, 0}, {"预算为负", s, -3}, {"正好边界", "你好", 3}, {"一个半字", "你好", 4}, {"短于预算", "hi", 10}, {"4 字节字符", "😀😀", 5}, } for _, c := range cases { got := ClipBytes(c.s, c.n) if !utf8.ValidString(got) { t.Errorf("%s:结果非法 UTF-8(%q ← %q 取 %d 字节)", c.name, got, c.s, c.n) } if len(got) > c.n && c.n > 0 { t.Errorf("%s:超出预算 %d 字节,得到 %d", c.name, c.n, len(got)) } if c.n >= len(c.s) && got != c.s { t.Errorf("%s:没超预算却改动了原文 %q", c.name, got) } } } func TestClipRunesCountsCharacters(t *testing.T) { if got := ClipRunes("删除全部缓存", 3); got != "删除全" { t.Errorf("按字截断 = %q,want 删除全", got) } // 按码点算:😀 是 1 个,而 🗑️ 是「U+1F5D1 + 变体选择符 U+FE0F」两个码点。 // 这不是切坏字符(每个码点都完整),只是"字数"的口径本来就按码点—— // 要按终端显示宽度算,用带宽度版本的截断(TUI 侧是 cutDisplay)。 if got := ClipRunes("😀🗑️x", 2); got != "😀🗑" { t.Errorf("按码点计数不对:%q", got) } if got := ClipRunes("😀x", 1); got != "😀" { t.Errorf("4 字节字符应整体保留:%q", got) } if got := ClipRunes("abc", 0); got != "" { t.Errorf("0 应得空串,得到 %q", got) } if got := ClipRunes("abc", 9); got != "abc" { t.Errorf("未超字数却改动了原文:%q", got) } // 长串必须仍然是合法 UTF-8(这是这类 bug 的表现形式)。 long := strings.Repeat("中文", 500) if !utf8.ValidString(ClipRunes(long, 3)) { t.Error("按字截断产生了非法字符") } } // TestClipBothValidOnEveryOffset 是回归护栏:对同一串中文,任意预算下两种截断 // 都必须落在字符边界上。原来的 `s[:n]` 写法在这里会成片失败。 func TestClipBothValidOnEveryOffset(t *testing.T) { s := "改一下 a.txt:把中文标题与 emoji 😀 都留下" for n := 0; n <= len(s)+2; n++ { if b := ClipBytes(s, n); !utf8.ValidString(b) { t.Fatalf("ClipBytes(%d) 非法:%q", n, b) } } for n := 0; n <= utf8.RuneCountInString(s)+2; n++ { r := ClipRunes(s, n) if !utf8.ValidString(r) { t.Fatalf("ClipRunes(%d) 非法:%q", n, r) } } }