Files
noitu/server/internal/dictionary/store_test.go
T
tiennm99 e8b76cc643 feat(dictionary): add read-only store over the game wordlist
Load the whole dictionary into maps at Open and close the database before
Open returns. The plan called for per-lookup SQLite, but a round-trip
benchmarked at 55us against 8.9ns for a map hit, and the hard bot in a
later phase explores hundreds of candidates inside a 150ms budget. The
in-memory form is also simpler: no connection pool, no prepared
statements, no tail latency. Costs ~70ms and ~7.8MB at startup.

Resolve returns the canonical word, never the spelling the player typed.
Canonicalization moves either end: about half the aliases differ in the
last syllable and more than a third in the first, so "sy hai" resolves to
"si hai". FirstSyllable and LastSyllable report the canonical's ends, and
the engine must chain on those or it will reject legal moves.

WordsStartingWith yields an iterator rather than the backing slice. A
caller could otherwise sort, shuffle or append into dictionary state:
verified that a write landed in the store, that most buckets have spare
capacity for append to scribble into, and that concurrent callers race.

Open validates what it loaded against the builder's recorded word count,
cross-checks every out-degree against the words actually indexed, and
rejects orphan aliases. A truncated database otherwise opens cleanly and
the server starts, rejects every word, and fails every room creation.

RandomOpeningWord picks from a pre-sorted slice by binary search instead
of rebuilding a filtered copy per call, cutting room creation from 374us
and 720KB to 18ns and no allocation.

Escape the database path when building the URI: SQLite reads # as a
fragment delimiter, so an unescaped path opens a different file and
reports a misleading schema error.

The store does not log. A library writing to the global logger fights
structured logging later, and the caller has WordCount, AliasCount and
License to state the CC BY-SA attribution itself.
2026-09-04 16:52:39 +07:00

544 lines
15 KiB
Go

package dictionary
import (
"database/sql"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"github.com/tiennm99dev/noitu/server/internal/vietnamese"
_ "modernc.org/sqlite"
)
const fixtureSchema = `
CREATE TABLE words (word TEXT PRIMARY KEY, first TEXT NOT NULL, last TEXT NOT NULL, syllables INTEGER NOT NULL) WITHOUT ROWID;
CREATE INDEX idx_words_first ON words(first);
CREATE TABLE syllables (syllable TEXT PRIMARY KEY, out_degree INTEGER NOT NULL) WITHOUT ROWID;
CREATE TABLE aliases (variant TEXT PRIMARY KEY, canonical TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
`
// fixtureAt builds a miniature dictionary with the same schema the builder
// emits. Tests never depend on the real 48k-word database, so they stay fast
// and run in CI without the 179 MB upstream download.
//
// Takes testing.TB so benchmarks get working cleanup: a zero-value testing.T
// never runs its Cleanup funcs, which leaks a temp directory per benchmark.
func fixtureAt(tb testing.TB, dir string) string {
tb.Helper()
path := filepath.Join(dir, "noitu.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
tb.Fatal(err)
}
defer db.Close()
data := fixtureSchema + `
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','7');
INSERT INTO words VALUES
('pháp luật','pháp','luật',2),
('pháp lý','pháp','lý',2),
('luật lệ','luật','lệ',2),
('lý do','lý','do',2),
('vô tuyến điện','vô','điện',3),
('công nghiệp hoá dầu','công','dầu',4),
-- ends on "pháp", the only syllable with more than one continuation, so
-- minOutDegree filtering has something to actually filter on.
('ngữ pháp','ngữ','pháp',2);
INSERT INTO syllables VALUES
('pháp',2),('luật',1),('lý',1),('lệ',0),('do',0),('vô',1),('điện',0),('công',1),('dầu',0),('ngữ',1);
-- "pháp lí" drifts in the LAST syllable, "luâto lệ" in the FIRST.
INSERT INTO aliases VALUES ('pháp lí','pháp lý'),('luâto lệ','luật lệ');
`
if _, err := db.Exec(data); err != nil {
tb.Fatal(err)
}
return path
}
func fixture(tb testing.TB) *Store {
tb.Helper()
store, err := Open(fixtureAt(tb, tb.TempDir()))
if err != nil {
tb.Fatalf("Open: %v", err)
}
return store
}
// writeDB creates a database from arbitrary SQL, for the malformed-input tests.
func writeDB(tb testing.TB, sqlText string) string {
tb.Helper()
path := filepath.Join(tb.TempDir(), "test.db")
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
tb.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(sqlText); err != nil {
tb.Fatal(err)
}
return path
}
func TestOpenMissingFile(t *testing.T) {
if _, err := Open(filepath.Join(t.TempDir(), "absent.db")); err == nil {
t.Fatal("Open succeeded on a missing file, want error")
}
}
func TestOpenWrongSchema(t *testing.T) {
path := writeDB(t, `CREATE TABLE unrelated (x TEXT)`)
if _, err := Open(path); err == nil {
t.Fatal("Open succeeded on a database with the wrong schema, want error")
}
}
// A truncated dictionary has the right schema and opens cleanly. Left
// unchecked, the server starts, rejects every word a player types, and fails
// every room creation.
func TestOpenEmptyDictionary(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','48216');`)
_, err := Open(path)
if err == nil {
t.Fatal("Open succeeded on an empty dictionary, want error")
}
if !strings.Contains(err.Error(), "incomplete") {
t.Errorf("error %q does not explain that the dictionary is incomplete", err)
}
}
// A syllables table that disagrees with the words table would tell the bot a
// syllable has continuations that cannot be supplied.
func TestOpenInconsistentOutDegree(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',7),('luật',0);`)
if _, err := Open(path); err == nil {
t.Fatal("Open succeeded with a stale syllables table, want error")
}
}
func TestOpenOrphanAlias(t *testing.T) {
path := writeDB(t, fixtureSchema+`
INSERT INTO meta VALUES ('source_license','CC BY-SA 4.0'),('word_count','1');
INSERT INTO words VALUES ('pháp luật','pháp','luật',2);
INSERT INTO syllables VALUES ('pháp',1),('luật',0);
INSERT INTO aliases VALUES ('phap luat','không tồn tại');`)
if _, err := Open(path); err == nil {
t.Fatal("Open succeeded with an alias pointing at a missing word, want error")
}
}
// SQLite reads '#' as a URI fragment delimiter, so an unescaped path
// containing one opens a different file and reports a misleading schema error.
func TestOpenPathWithHash(t *testing.T) {
dir := t.TempDir()
src := fixtureAt(t, dir)
hashed := filepath.Join(dir, "dict#1.db")
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(hashed, data, 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(hashed)
if err != nil {
t.Fatalf("Open on a path containing '#': %v", err)
}
if s.WordCount() != 7 {
t.Errorf("WordCount = %d, want 7", s.WordCount())
}
}
// The store must never write to the dictionary: production runs it from a
// read-only filesystem, and a stray journal file would break that.
func TestOpenIsReadOnly(t *testing.T) {
dir := t.TempDir()
path := fixtureAt(t, dir)
before, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
s := mustOpen(t, path)
s.Resolve("pháp luật")
_, _ = s.RandomOpeningWord(1)
after, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if before.Size() != after.Size() || !before.ModTime().Equal(after.ModTime()) {
t.Error("dictionary file changed after read-only use")
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
switch filepath.Ext(e.Name()) {
case ".db-wal", ".db-shm", ".db-journal":
t.Errorf("read-only open created side file %q", e.Name())
}
}
}
func mustOpen(tb testing.TB, path string) *Store {
tb.Helper()
s, err := Open(path)
if err != nil {
tb.Fatalf("Open: %v", err)
}
return s
}
func TestResolveExactWord(t *testing.T) {
s := fixture(t)
canonical, ok := s.Resolve("pháp luật")
if !ok || canonical != "pháp luật" {
t.Errorf("Resolve = (%q, %v), want (%q, true)", canonical, ok, "pháp luật")
}
}
// A player typing an accepted variant must reach the canonical entry, and the
// caller must receive the canonical spelling so the chain links correctly.
func TestResolveAlias(t *testing.T) {
s := fixture(t)
canonical, ok := s.Resolve("pháp lí")
if !ok {
t.Fatal("alias did not resolve")
}
if canonical != "pháp lý" {
t.Errorf("Resolve(%q) = %q, want the canonical %q", "pháp lí", canonical, "pháp lý")
}
}
// Canonicalization can move the FIRST syllable too. An engine that link-checked
// against what the player typed would reject this legal move.
func TestResolveAliasDriftsFirstSyllable(t *testing.T) {
s := fixture(t)
canonical, ok := s.Resolve("luâto lệ")
if !ok {
t.Fatal("alias did not resolve")
}
first, ok := s.FirstSyllable(canonical)
if !ok {
t.Fatal("canonical has no first syllable")
}
if first == "luâto" {
t.Error("FirstSyllable returned the typed syllable, not the canonical one")
}
if first != "luật" {
t.Errorf("FirstSyllable(%q) = %q, want %q", canonical, first, "luật")
}
}
func TestResolveUnknown(t *testing.T) {
s := fixture(t)
if canonical, ok := s.Resolve("không tồn tại"); ok {
t.Errorf("Resolve returned %q for an unknown word, want not found", canonical)
}
}
func TestFirstAndLastSyllable(t *testing.T) {
s := fixture(t)
tests := []struct{ word, first, last string }{
{"pháp luật", "pháp", "luật"},
{"vô tuyến điện", "vô", "điện"},
{"công nghiệp hoá dầu", "công", "dầu"},
}
for _, tc := range tests {
first, ok := s.FirstSyllable(tc.word)
if !ok || first != tc.first {
t.Errorf("FirstSyllable(%q) = (%q, %v), want (%q, true)", tc.word, first, ok, tc.first)
}
last, ok := s.LastSyllable(tc.word)
if !ok || last != tc.last {
t.Errorf("LastSyllable(%q) = (%q, %v), want (%q, true)", tc.word, last, ok, tc.last)
}
}
// An alias is an accepted spelling, not a playable entry.
if _, ok := s.LastSyllable("pháp lí"); ok {
t.Error("LastSyllable succeeded on an alias, want false")
}
if _, ok := s.FirstSyllable("không tồn tại"); ok {
t.Error("FirstSyllable succeeded on an unknown word, want false")
}
}
func TestWordsStartingWith(t *testing.T) {
s := fixture(t)
got := slices.Collect(s.WordsStartingWith("pháp"))
// Asserted in the documented order, not sorted first: the order is a
// guarantee the bot relies on for reproducibility.
want := []string{"pháp luật", "pháp lý"}
if !slices.Equal(got, want) {
t.Errorf("WordsStartingWith(%q) = %v, want %v in this order", "pháp", got, want)
}
if got := slices.Collect(s.WordsStartingWith("lệ")); len(got) != 0 {
t.Errorf("WordsStartingWith(%q) = %v, want none", "lệ", got)
}
if got := slices.Collect(s.WordsStartingWith("không-có")); len(got) != 0 {
t.Errorf("WordsStartingWith on an unknown syllable = %v, want none", got)
}
}
// The iterator must not expose Store state: a caller collecting and sorting
// used to reorder the dictionary's own slice.
func TestWordsStartingWithIsolatesStoreState(t *testing.T) {
s := fixture(t)
collected := slices.Collect(s.WordsStartingWith("pháp"))
slices.Reverse(collected)
collected[0] = "MUTATED"
again := slices.Collect(s.WordsStartingWith("pháp"))
if !slices.Equal(again, []string{"pháp luật", "pháp lý"}) {
t.Errorf("store state changed after a caller mutated its collected slice: %v", again)
}
}
// A consumer that stops early must not keep iterating.
func TestWordsStartingWithEarlyExit(t *testing.T) {
s := fixture(t)
seen := 0
for range s.WordsStartingWith("pháp") {
seen++
break
}
if seen != 1 {
t.Errorf("iterated %d words after break, want 1", seen)
}
}
func TestWordsStartingWithExcludesAliases(t *testing.T) {
s := fixture(t)
for w := range s.WordsStartingWith("pháp") {
if w == "pháp lí" {
t.Error("alias appeared among playable words")
}
}
}
func TestOutDegree(t *testing.T) {
s := fixture(t)
for syllable, want := range map[string]int{"pháp": 2, "luật": 1, "lệ": 0, "do": 0} {
got, err := s.OutDegree(syllable)
if err != nil {
t.Errorf("OutDegree(%q): %v", syllable, err)
continue
}
if got != want {
t.Errorf("OutDegree(%q) = %d, want %d", syllable, got, want)
}
}
}
// A syllable with no continuations and one the dictionary has never seen are
// different situations, and the engine treats them differently.
func TestOutDegreeUnknownSyllable(t *testing.T) {
s := fixture(t)
if _, err := s.OutDegree("xyzzy"); !errors.Is(err, ErrNotFound) {
t.Errorf("OutDegree of an unknown syllable = %v, want ErrNotFound", err)
}
}
func TestRandomOpeningWord(t *testing.T) {
s := fixture(t)
// Only words ending on a syllable with a continuation are eligible.
eligible := map[string]bool{"pháp luật": true, "pháp lý": true, "ngữ pháp": true}
for i := 0; i < 50; i++ {
word, err := s.RandomOpeningWord(1)
if err != nil {
t.Fatal(err)
}
if !eligible[word] {
t.Fatalf("opening word %q ends on a dead end", word)
}
}
}
// The minimum must actually filter, not just be accepted.
func TestRandomOpeningWordRespectsMinimum(t *testing.T) {
s := fixture(t)
for i := 0; i < 50; i++ {
word, err := s.RandomOpeningWord(2)
if err != nil {
t.Fatal(err)
}
last, _ := s.LastSyllable(word)
degree, err := s.OutDegree(last)
if err != nil {
t.Fatal(err)
}
if degree < 2 {
t.Fatalf("opening word %q ends on %q with out-degree %d, want >= 2", word, last, degree)
}
}
}
func TestRandomOpeningWordImpossible(t *testing.T) {
s := fixture(t)
if _, err := s.RandomOpeningWord(99); err == nil {
t.Fatal("RandomOpeningWord succeeded with an unreachable minimum, want error")
}
}
func TestCountsAndLicense(t *testing.T) {
s := fixture(t)
if got := s.WordCount(); got != 7 {
t.Errorf("WordCount = %d, want 7", got)
}
if got := s.AliasCount(); got != 2 {
t.Errorf("AliasCount = %d, want 2", got)
}
if got := s.License(); got != "CC BY-SA 4.0" {
t.Errorf("License = %q, want %q", got, "CC BY-SA 4.0")
}
}
// The corpus and player input must normalize identically, or a word in the
// dictionary stops matching the same string typed by a player.
func TestNormalizeRoundTrip(t *testing.T) {
s := fixture(t)
for _, raw := range []string{"PHÁP LUẬT", " pháp luật ", "Pháp\tLuật", "pháp luật"} {
word, syllables, err := vietnamese.Normalize(raw)
if err != nil {
t.Fatalf("Normalize(%q): %v", raw, err)
}
if !vietnamese.HasEnoughSyllables(syllables) {
t.Errorf("Normalize(%q) produced too few syllables", raw)
}
if _, ok := s.Resolve(word); !ok {
t.Errorf("Normalize(%q) = %q, which the dictionary does not resolve", raw, word)
}
}
}
// The server serves every room from one Store, so concurrent reads must be
// safe. Meaningful only under -race.
func TestConcurrentReads(t *testing.T) {
s := fixture(t)
const goroutines = 100
const iterations = 200
var wg sync.WaitGroup
errs := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < iterations; j++ {
if _, ok := s.Resolve("pháp luật"); !ok {
errs <- errors.New("resolve failed")
return
}
// Collect, so each goroutine holds its own slice — the shape a
// real caller uses.
if got := slices.Collect(s.WordsStartingWith("pháp")); len(got) != 2 {
errs <- errors.New("unexpected candidate count")
return
}
if _, err := s.OutDegree("pháp"); err != nil {
errs <- err
return
}
if _, err := s.RandomOpeningWord(1); err != nil {
errs <- err
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("concurrent read failed: %v", err)
}
}
// Latency is measured by benchmark, not by wrapping time.Now() around a ~13ns
// map lookup: the platform clock quantizes to ~1ms, so such a test measures
// timer resolution rather than the code.
func BenchmarkResolve(b *testing.B) {
s := fixture(b)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, ok := s.Resolve("pháp luật"); !ok {
b.Fatal("resolve failed")
}
}
}
func BenchmarkWordsStartingWith(b *testing.B) {
s := fixture(b)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for range s.WordsStartingWith("pháp") {
}
}
}
func BenchmarkOutDegree(b *testing.B) {
s := fixture(b)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := s.OutDegree("pháp"); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkRandomOpeningWord(b *testing.B) {
s := fixture(b)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := s.RandomOpeningWord(1); err != nil {
b.Fatal(err)
}
}
}