Files
Erupe/common/stringsupport/string_convert_test.go
Houmgaor 9b69564c49 fix: prevent server crash on unsupported Shift-JIS characters (#116)
UTF8ToSJIS panicked when encountering characters outside the Shift-JIS
range (emoji, Lenny faces, cuneiform, etc.), crashing the server when
such characters were sent via the Discord relay channel.

Replace the panic with graceful filtering that drops unmappable runes
and preserves valid content. Also fix ToNGWord index-out-of-range panic
on empty encoder output.

Closes #116
2026-02-16 18:16:42 +01:00

566 lines
12 KiB
Go

package stringsupport
import (
"bytes"
"testing"
)
func TestUTF8ToSJIS(t *testing.T) {
tests := []struct {
name string
input string
}{
{"ascii", "Hello World"},
{"numbers", "12345"},
{"symbols", "!@#$%"},
{"japanese_hiragana", "あいうえお"},
{"japanese_katakana", "アイウエオ"},
{"japanese_kanji", "日本語"},
{"mixed", "Hello世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := UTF8ToSJIS(tt.input)
if len(result) == 0 && len(tt.input) > 0 {
t.Error("UTF8ToSJIS returned empty result for non-empty input")
}
})
}
}
func TestSJISToUTF8(t *testing.T) {
// Test ASCII characters (which are the same in SJIS and UTF-8)
asciiBytes := []byte("Hello World")
result := SJISToUTF8(asciiBytes)
if result != "Hello World" {
t.Errorf("SJISToUTF8() = %q, want %q", result, "Hello World")
}
}
func TestUTF8ToSJIS_RoundTrip(t *testing.T) {
// Test round-trip conversion for ASCII
original := "Hello World 123"
sjis := UTF8ToSJIS(original)
back := SJISToUTF8(sjis)
if back != original {
t.Errorf("Round-trip failed: got %q, want %q", back, original)
}
}
func TestToNGWord(t *testing.T) {
tests := []struct {
name string
input string
minLen int
checkFn func(t *testing.T, result []uint16)
}{
{
name: "ascii characters",
input: "ABC",
minLen: 3,
checkFn: func(t *testing.T, result []uint16) {
if result[0] != uint16('A') {
t.Errorf("result[0] = %d, want %d", result[0], 'A')
}
},
},
{
name: "numbers",
input: "123",
minLen: 3,
checkFn: func(t *testing.T, result []uint16) {
if result[0] != uint16('1') {
t.Errorf("result[0] = %d, want %d", result[0], '1')
}
},
},
{
name: "japanese characters",
input: "あ",
minLen: 1,
checkFn: func(t *testing.T, result []uint16) {
if len(result) == 0 {
t.Error("result should not be empty")
}
},
},
{
name: "empty string",
input: "",
minLen: 0,
checkFn: func(t *testing.T, result []uint16) {
if len(result) != 0 {
t.Errorf("result length = %d, want 0", len(result))
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToNGWord(tt.input)
if len(result) < tt.minLen {
t.Errorf("ToNGWord() length = %d, want at least %d", len(result), tt.minLen)
}
if tt.checkFn != nil {
tt.checkFn(t, result)
}
})
}
}
func TestPaddedString(t *testing.T) {
tests := []struct {
name string
input string
size uint
transform bool
wantLen uint
}{
{"short string", "Hello", 10, false, 10},
{"exact size", "Test", 5, false, 5},
{"longer than size", "This is a long string", 10, false, 10},
{"empty string", "", 5, false, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := PaddedString(tt.input, tt.size, tt.transform)
if uint(len(result)) != tt.wantLen {
t.Errorf("PaddedString() length = %d, want %d", len(result), tt.wantLen)
}
// Verify last byte is null
if result[len(result)-1] != 0 {
t.Error("PaddedString() should end with null byte")
}
})
}
}
func TestPaddedString_NullTermination(t *testing.T) {
result := PaddedString("Test", 10, false)
if result[9] != 0 {
t.Error("Last byte should be null")
}
// First 4 bytes should be "Test"
if !bytes.Equal(result[0:4], []byte("Test")) {
t.Errorf("First 4 bytes = %v, want %v", result[0:4], []byte("Test"))
}
}
func TestCSVAdd(t *testing.T) {
tests := []struct {
name string
csv string
value int
expected string
}{
{"add to empty", "", 1, "1"},
{"add to existing", "1,2,3", 4, "1,2,3,4"},
{"add duplicate", "1,2,3", 2, "1,2,3"},
{"add to single", "5", 10, "5,10"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVAdd(tt.csv, tt.value)
if result != tt.expected {
t.Errorf("CSVAdd(%q, %d) = %q, want %q", tt.csv, tt.value, result, tt.expected)
}
})
}
}
func TestCSVRemove(t *testing.T) {
tests := []struct {
name string
csv string
value int
check func(t *testing.T, result string)
}{
{
name: "remove from middle",
csv: "1,2,3,4,5",
value: 3,
check: func(t *testing.T, result string) {
if CSVContains(result, 3) {
t.Error("Result should not contain 3")
}
if CSVLength(result) != 4 {
t.Errorf("Result length = %d, want 4", CSVLength(result))
}
},
},
{
name: "remove from start",
csv: "1,2,3",
value: 1,
check: func(t *testing.T, result string) {
if CSVContains(result, 1) {
t.Error("Result should not contain 1")
}
},
},
{
name: "remove non-existent",
csv: "1,2,3",
value: 99,
check: func(t *testing.T, result string) {
if CSVLength(result) != 3 {
t.Errorf("Length should remain 3, got %d", CSVLength(result))
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVRemove(tt.csv, tt.value)
tt.check(t, result)
})
}
}
func TestCSVContains(t *testing.T) {
tests := []struct {
name string
csv string
value int
expected bool
}{
{"contains in middle", "1,2,3,4,5", 3, true},
{"contains at start", "1,2,3", 1, true},
{"contains at end", "1,2,3", 3, true},
{"does not contain", "1,2,3", 5, false},
{"empty csv", "", 1, false},
{"single value match", "42", 42, true},
{"single value no match", "42", 43, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVContains(tt.csv, tt.value)
if result != tt.expected {
t.Errorf("CSVContains(%q, %d) = %v, want %v", tt.csv, tt.value, result, tt.expected)
}
})
}
}
func TestCSVLength(t *testing.T) {
tests := []struct {
name string
csv string
expected int
}{
{"empty", "", 0},
{"single", "1", 1},
{"multiple", "1,2,3,4,5", 5},
{"two", "10,20", 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVLength(tt.csv)
if result != tt.expected {
t.Errorf("CSVLength(%q) = %d, want %d", tt.csv, result, tt.expected)
}
})
}
}
func TestCSVElems(t *testing.T) {
tests := []struct {
name string
csv string
expected []int
}{
{"empty", "", []int{}},
{"single", "42", []int{42}},
{"multiple", "1,2,3,4,5", []int{1, 2, 3, 4, 5}},
{"negative numbers", "-1,0,1", []int{-1, 0, 1}},
{"large numbers", "100,200,300", []int{100, 200, 300}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVElems(tt.csv)
if len(result) != len(tt.expected) {
t.Errorf("CSVElems(%q) length = %d, want %d", tt.csv, len(result), len(tt.expected))
}
for i, v := range tt.expected {
if i >= len(result) || result[i] != v {
t.Errorf("CSVElems(%q)[%d] = %d, want %d", tt.csv, i, result[i], v)
}
}
})
}
}
func TestCSVGetIndex(t *testing.T) {
csv := "10,20,30,40,50"
tests := []struct {
name string
index int
expected int
}{
{"first", 0, 10},
{"middle", 2, 30},
{"last", 4, 50},
{"out of bounds", 10, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVGetIndex(csv, tt.index)
if result != tt.expected {
t.Errorf("CSVGetIndex(%q, %d) = %d, want %d", csv, tt.index, result, tt.expected)
}
})
}
}
func TestCSVSetIndex(t *testing.T) {
tests := []struct {
name string
csv string
index int
value int
check func(t *testing.T, result string)
}{
{
name: "set first",
csv: "10,20,30",
index: 0,
value: 99,
check: func(t *testing.T, result string) {
if CSVGetIndex(result, 0) != 99 {
t.Errorf("Index 0 = %d, want 99", CSVGetIndex(result, 0))
}
},
},
{
name: "set middle",
csv: "10,20,30",
index: 1,
value: 88,
check: func(t *testing.T, result string) {
if CSVGetIndex(result, 1) != 88 {
t.Errorf("Index 1 = %d, want 88", CSVGetIndex(result, 1))
}
},
},
{
name: "set last",
csv: "10,20,30",
index: 2,
value: 77,
check: func(t *testing.T, result string) {
if CSVGetIndex(result, 2) != 77 {
t.Errorf("Index 2 = %d, want 77", CSVGetIndex(result, 2))
}
},
},
{
name: "set out of bounds",
csv: "10,20,30",
index: 10,
value: 99,
check: func(t *testing.T, result string) {
// Should not modify the CSV
if CSVLength(result) != 3 {
t.Errorf("CSV length changed when setting out of bounds")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CSVSetIndex(tt.csv, tt.index, tt.value)
tt.check(t, result)
})
}
}
func TestCSV_CompleteWorkflow(t *testing.T) {
// Test a complete workflow
csv := ""
// Add elements
csv = CSVAdd(csv, 10)
csv = CSVAdd(csv, 20)
csv = CSVAdd(csv, 30)
if CSVLength(csv) != 3 {
t.Errorf("Length = %d, want 3", CSVLength(csv))
}
// Check contains
if !CSVContains(csv, 20) {
t.Error("Should contain 20")
}
// Get element
if CSVGetIndex(csv, 1) != 20 {
t.Errorf("Index 1 = %d, want 20", CSVGetIndex(csv, 1))
}
// Set element
csv = CSVSetIndex(csv, 1, 99)
if CSVGetIndex(csv, 1) != 99 {
t.Errorf("Index 1 = %d, want 99 after set", CSVGetIndex(csv, 1))
}
// Remove element
csv = CSVRemove(csv, 99)
if CSVContains(csv, 99) {
t.Error("Should not contain 99 after removal")
}
if CSVLength(csv) != 2 {
t.Errorf("Length = %d, want 2 after removal", CSVLength(csv))
}
}
func BenchmarkCSVAdd(b *testing.B) {
csv := "1,2,3,4,5"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CSVAdd(csv, 6)
}
}
func BenchmarkCSVContains(b *testing.B) {
csv := "1,2,3,4,5,6,7,8,9,10"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CSVContains(csv, 5)
}
}
func BenchmarkCSVRemove(b *testing.B) {
csv := "1,2,3,4,5,6,7,8,9,10"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CSVRemove(csv, 5)
}
}
func BenchmarkCSVElems(b *testing.B) {
csv := "1,2,3,4,5,6,7,8,9,10"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = CSVElems(csv)
}
}
func TestUTF8ToSJIS_UnsupportedCharacters(t *testing.T) {
// Regression test for PR #116: Characters outside the Shift-JIS range
// (e.g. Lenny face, cuneiform) previously caused a panic in UTF8ToSJIS,
// crashing the server when relayed from Discord.
tests := []struct {
name string
input string
}{
{"lenny_face", "( ͡° ͜ʖ ͡°)"},
{"cuneiform", "𒀜"},
{"emoji", "Hello 🎮 World"},
{"mixed_unsupported", "Test ͡° message 𒀜 here"},
{"zalgo_text", "H̷e̸l̵l̶o̷"},
{"only_unsupported", "🎮🎲🎯"},
{"cyrillic", "Привет"},
{"arabic", "مرحبا"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Must not panic - the old code would panic here
defer func() {
if r := recover(); r != nil {
t.Errorf("UTF8ToSJIS panicked on input %q: %v", tt.input, r)
}
}()
result := UTF8ToSJIS(tt.input)
if result == nil {
t.Error("UTF8ToSJIS returned nil")
}
})
}
}
func TestUTF8ToSJIS_PreservesValidContent(t *testing.T) {
// Verify that valid Shift-JIS content is preserved when mixed with
// unsupported characters.
tests := []struct {
name string
input string
expected string
}{
{"ascii_with_emoji", "Hello 🎮 World", "Hello World"},
{"japanese_with_emoji", "テスト🎮データ", "テストデータ"},
{"only_valid", "Hello World", "Hello World"},
{"only_invalid", "🎮🎲🎯", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sjis := UTF8ToSJIS(tt.input)
roundTripped := SJISToUTF8(sjis)
if roundTripped != tt.expected {
t.Errorf("UTF8ToSJIS(%q) round-tripped to %q, want %q", tt.input, roundTripped, tt.expected)
}
})
}
}
func TestToNGWord_UnsupportedCharacters(t *testing.T) {
// ToNGWord also calls UTF8ToSJIS internally, so it must not panic either.
inputs := []string{"( ͡° ͜ʖ ͡°)", "🎮", "Hello 🎮 World"}
for _, input := range inputs {
t.Run(input, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("ToNGWord panicked on input %q: %v", input, r)
}
}()
_ = ToNGWord(input)
})
}
}
func BenchmarkUTF8ToSJIS(b *testing.B) {
text := "Hello World テスト"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = UTF8ToSJIS(text)
}
}
func BenchmarkSJISToUTF8(b *testing.B) {
text := []byte("Hello World")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = SJISToUTF8(text)
}
}
func BenchmarkPaddedString(b *testing.B) {
text := "Test String"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = PaddedString(text, 50, false)
}
}
func BenchmarkToNGWord(b *testing.B) {
text := "TestString"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = ToNGWord(text)
}
}