Files
tiennm99 8223f40b16 feat(server): counters, readiness, drain mode and a version stamp
expvar counters for connections, rooms, games, submissions by rejection
reason, eliminations, chat, joins and bot moves, served on a separate
debug address so they never sit on the public mux, plus one structured
word_rejected log line per refused word carrying the normalized word and
its link. GET /readyz flips to 503 while draining; SIGTERM stops new
rooms, waits up to NOITU_DRAIN_TIMEOUT for live games, then shuts down.
GET /version and the startup log carry the build's git describe.

Fuzz targets for the frame decoder, the text sanitizer and Vietnamese
normalization; the last one found that composing before lowercasing
could leave a non-NFC result, now recomposed after lowering.

CI runs on dev as well as main, gates gofmt and golangci-lint, tracks the
buf major instead of an exact pin, and dependabot watches every
ecosystem. The lint findings that had been hidden by the default
per-issue cap are fixed.
2026-09-21 01:17:52 +07:00

142 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package vietnamese
import (
"errors"
"strings"
"testing"
"golang.org/x/text/unicode/norm"
)
func TestNormalize(t *testing.T) {
tests := []struct {
name string
raw string
want string
syllables int
}{
{"plain two syllables", "pháp luật", "pháp luật", 2},
{"uppercase", "PHÁP LUẬT", "pháp luật", 2},
{"mixed case", "Pháp Luật", "pháp luật", 2},
{"leading and trailing space", " pháp luật ", "pháp luật", 2},
{"repeated spaces", "pháp luật", "pháp luật", 2},
{"tab separator", "pháp\tluật", "pháp luật", 2},
{"non-breaking space", "pháp luật", "pháp luật", 2},
{"newline separator", "pháp\nluật", "pháp luật", 2},
{"three syllables", "vô tuyến điện", "vô tuyến điện", 3},
{"four syllables", "công nghiệp hóa hiện", "công nghiệp hóa hiện", 4},
{"single syllable passes through", "pháp", "pháp", 1},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
word, syllables, err := Normalize(tc.raw)
if err != nil {
t.Fatalf("Normalize(%q) returned error: %v", tc.raw, err)
}
if word != tc.want {
t.Errorf("word = %q, want %q", word, tc.want)
}
if len(syllables) != tc.syllables {
t.Errorf("got %d syllables, want %d", len(syllables), tc.syllables)
}
})
}
}
// A word typed on one keyboard can arrive decomposed and on another composed.
// Both must normalize to the same string, or database lookups fail for reasons
// invisible to the player.
func TestNormalizeUnicodeEquivalence(t *testing.T) {
composed := "ngôn ngữ" // ữ as a single codepoint
decomposed := "ngôn ngữ" // o+circumflex, u+horn+tilde
gotComposed, _, err := Normalize(composed)
if err != nil {
t.Fatal(err)
}
gotDecomposed, _, err := Normalize(decomposed)
if err != nil {
t.Fatal(err)
}
if gotComposed != gotDecomposed {
t.Errorf("composed %q and decomposed %q normalized differently: %q vs %q",
composed, decomposed, gotComposed, gotDecomposed)
}
}
func TestNormalizeEmpty(t *testing.T) {
for _, raw := range []string{"", " ", "\t\n", " "} {
if _, _, err := Normalize(raw); !errors.Is(err, ErrEmpty) {
t.Errorf("Normalize(%q) error = %v, want ErrEmpty", raw, err)
}
}
}
// FuzzNormalize guards the one function two processes have to agree on
// byte-for-byte: the corpus builder normalizes with it at build time, the
// server normalizes with it on every submission, and a divergence between
// them would silently unmatch a word the dictionary actually has. The
// invariants are exactly what the package doc promises — NFC, lowercase,
// single-spaced — plus no panic on arbitrary input, and that the returned
// word and syllables always agree with each other.
func FuzzNormalize(f *testing.F) {
seeds := []string{
"pháp luật", "PHÁP LUẬT", "Pháp Luật", " pháp luật ", "pháp luật",
"pháp\tluật", "pháp luật", "pháp\nluật", "vô tuyến điện",
"công nghiệp hóa hiện", "pháp", "", " ", "\t\n", " ",
"ngôn ngữ", "ngôn ngữ",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, raw string) {
word, syllables, err := Normalize(raw)
if err != nil {
if !errors.Is(err, ErrEmpty) {
t.Fatalf("Normalize(%q) returned an unexpected error: %v", raw, err)
}
if word != "" || syllables != nil {
t.Fatalf("Normalize(%q) returned ErrEmpty but non-empty output: %q %v", raw, word, syllables)
}
return
}
if !norm.NFC.IsNormalString(word) {
t.Fatalf("Normalize(%q) = %q is not NFC", raw, word)
}
if strings.ToLower(word) != word {
t.Fatalf("Normalize(%q) = %q is not fully lowercase", raw, word)
}
if strings.Join(strings.Fields(word), " ") != word {
t.Fatalf("Normalize(%q) = %q is not single-spaced and trimmed", raw, word)
}
if len(syllables) == 0 {
t.Fatalf("Normalize(%q) returned no error but no syllables", raw)
}
if strings.Join(syllables, " ") != word {
t.Fatalf("Normalize(%q): word %q does not match its own syllables %v", raw, word, syllables)
}
})
}
func TestHasEnoughSyllables(t *testing.T) {
tests := []struct {
syllables []string
want bool
}{
{[]string{"pháp"}, false},
{[]string{"pháp", "luật"}, true},
{[]string{"vô", "tuyến", "điện"}, true},
{nil, false},
}
for _, tc := range tests {
if got := HasEnoughSyllables(tc.syllables); got != tc.want {
t.Errorf("HasEnoughSyllables(%v) = %v, want %v", tc.syllables, got, tc.want)
}
}
}