feat(admin): online dictionary update — upload archive, preview word diff, install & activate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 23s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s

Replace the dictionary hot-reload with an online update flow in the GM console
(/_gm/dictionary): the operator uploads scrabble-dawg-vX.Y.Z.tar.gz, previews the
per-variant words added/removed against the active dictionary, and confirms to
install + activate it. Versions are immutable; in-progress games keep their pinned
version while new games use the new one.

- engine: DiffWords (enumerate both DAWGs, decode only the differences), OpenFinder,
  Registry.Finder, DictFiles; OpenWithVersions skips the .staging area.
- dictadmin: hardened release-archive validation + extraction (path-traversal,
  symlink, oversize, entry-count rejection) and staging -> install (atomic rename).
- game: active dictionary version persisted in the dictionary_state singleton
  (single source of truth, restored on boot), concurrency-safe accessor.
- storage: BACKEND_DICT_DIR is a named volume seeded from the image (nonroot-owned),
  so uploaded versions persist across redeploys; the build's DICT_VERSION labels the
  seed and equals the resident tag (BACKEND_DICT_VERSION).
- docs: ARCHITECTURE §5, FUNCTIONAL (+ru), backend README, TESTING, PRERELEASE.

Tests: engine + dictadmin unit; integration upload->preview->install->activate->
restart->pin->immutability->CSRF.
This commit is contained in:
Ilia Denisov
2026-06-13 23:29:06 +02:00
parent 5ff07da025
commit 0f3671f42d
31 changed files with 1590 additions and 78 deletions
+134
View File
@@ -0,0 +1,134 @@
package engine
import (
"errors"
"testing"
"github.com/iliadenisov/alphabet"
dawg "github.com/iliadenisov/dafsa"
)
// buildFinderB builds an in-memory finder over idx from the given words, each
// expressed as alphabet-index bytes. The words must be supplied in strictly
// increasing alphabet-index order, as the builder requires.
func buildFinderB(t *testing.T, idx alphabet.Indexer, words ...[]byte) dawg.Finder {
t.Helper()
b := dawg.New(idx)
for _, w := range words {
if err := b.AddB(w); err != nil {
t.Fatalf("addB %v: %v", w, err)
}
}
return b.Finish()
}
// mustDecode decodes index bytes through idx or fails the test.
func mustDecode(t *testing.T, idx alphabet.Indexer, word []byte) string {
t.Helper()
s, err := idx.Decode(word)
if err != nil {
t.Fatalf("decode %v: %v", word, err)
}
return s
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestDiffWords checks that DiffWords reports the words present only in the new
// dictionary as added and those present only in the old one as removed, decoded
// to characters through the variant's alphabet.
func TestDiffWords(t *testing.T) {
rs, _ := VariantEnglish.ruleset()
idx := rs.Alphabet
old := buildFinderB(t, idx, []byte{0}, []byte{0, 1}, []byte{2})
defer func() { _ = old.Close() }()
updated := buildFinderB(t, idx, []byte{0}, []byte{2}, []byte{2, 3})
defer func() { _ = updated.Close() }()
diff, err := DiffWords(VariantEnglish, old, updated)
if err != nil {
t.Fatalf("DiffWords: %v", err)
}
wantAdded := []string{mustDecode(t, idx, []byte{2, 3})}
wantRemoved := []string{mustDecode(t, idx, []byte{0, 1})}
if !equalStrings(diff.Added, wantAdded) {
t.Errorf("added = %v, want %v", diff.Added, wantAdded)
}
if !equalStrings(diff.Removed, wantRemoved) {
t.Errorf("removed = %v, want %v", diff.Removed, wantRemoved)
}
}
// TestDiffWordsIdentical checks that comparing a dictionary with itself yields an
// empty diff.
func TestDiffWordsIdentical(t *testing.T) {
rs, _ := VariantEnglish.ruleset()
f := buildFinderB(t, rs.Alphabet, []byte{0}, []byte{1}, []byte{1, 2})
defer func() { _ = f.Close() }()
diff, err := DiffWords(VariantEnglish, f, f)
if err != nil {
t.Fatalf("DiffWords: %v", err)
}
if len(diff.Added) != 0 || len(diff.Removed) != 0 {
t.Errorf("diff = %+v, want empty", diff)
}
}
// TestDiffWordsUnknownVariant checks that an unrecognised variant is rejected.
func TestDiffWordsUnknownVariant(t *testing.T) {
rs, _ := VariantEnglish.ruleset()
f := buildFinderB(t, rs.Alphabet, []byte{0})
defer func() { _ = f.Close() }()
if _, err := DiffWords(Variant(250), f, f); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("err = %v, want ErrUnknownVariant", err)
}
}
// TestOpenFinder checks that OpenFinder loads a committed DAWG from a directory.
func TestOpenFinder(t *testing.T) {
f, err := OpenFinder(testDictDir(), VariantEnglish)
if err != nil {
t.Fatalf("OpenFinder: %v", err)
}
defer func() { _ = f.Close() }()
if f.NumAdded() == 0 {
t.Error("english dictionary is empty")
}
}
// TestRegistryFinder checks that Finder returns the resident finder for a pair
// and the right sentinel when the version is absent.
func TestRegistryFinder(t *testing.T) {
if _, err := testReg.Finder(VariantEnglish, testVersion); err != nil {
t.Errorf("Finder(scrabble_en, %q): %v", testVersion, err)
}
if _, err := testReg.Finder(VariantEnglish, "absent"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("Finder absent version err = %v, want ErrUnknownVersion", err)
}
}
// TestDictFiles checks that DictFiles exposes the variant filename map as a copy
// the caller cannot use to mutate the engine's own map.
func TestDictFiles(t *testing.T) {
files := DictFiles()
if len(files) != len(Variants()) {
t.Fatalf("DictFiles has %d entries, want %d", len(files), len(Variants()))
}
files[VariantEnglish] = "tampered"
if again := DictFiles(); again[VariantEnglish] == "tampered" {
t.Error("DictFiles returned a shared map; mutating it changed the engine's map")
}
}