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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user