Files
scrabble-game/backend/internal/engine/reload_test.go
T
Ilia Denisov 95f5703372
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m2s
fix(engine): make .seed_version marker authoritative (no boot refusal)
The seed-drift guard shipped as refuse-boot: the backend exited when
BACKEND_DICT_VERSION disagreed with the flat dir's recorded .seed_version. On
the test contour that turned a harmless-in-intent action — bumping the
TEST_DICT_VERSION variable to the active release (v1.2.1) on a volume seeded as
v1.0.0 — into a crash loop, because DICT_VERSION is the *seed* of a fresh
volume, not the active version (which the admin console drives).

Make the marker authoritative instead: OpenWithVersions resolves the flat dir's
version from .seed_version when present and ignores bootVersion on an
already-seeded volume; bootVersion only seeds a fresh volume's marker. So a
bumped build seed on a live volume is a no-op (it can't relabel live bytes and
can't void games pinned to the prior label), and it correctly seeds the next
fresh volume. The subdirectory scan now skips the resolved seed, so a version
also present as a subdir (e.g. v1.2.1 uploaded via the console while the build
seed is bumped to v1.2.1) is still loaded rather than shadowed by the flat bytes.

Tests: marker-wins over a bumped boot version; a bumped boot keeps the matching
subdir resident (the live-contour case). Docs updated (ARCHITECTURE §5, READMEs,
compose/.env, PRERELEASE DV) from "refuses to boot" to "marker wins / ignored".

Verified locally against v1.2.1: gofmt, build, vet, unit, integration green.
2026-06-20 20:06:57 +02:00

239 lines
7.8 KiB
Go

package engine
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating
// dstDir). It is the fixture builder for the dictionary-reload tests, which need
// real DAWG files laid out in temporary version directories.
func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) {
t.Helper()
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dstDir, err)
}
name := dictFiles[v]
src, err := os.Open(filepath.Join(srcDir, name))
if err != nil {
t.Fatalf("open source dawg %s: %v", name, err)
}
defer func() { _ = src.Close() }()
dst, err := os.Create(filepath.Join(dstDir, name))
if err != nil {
t.Fatalf("create dest dawg %s: %v", name, err)
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, src); err != nil {
t.Fatalf("copy dawg %s: %v", name, err)
}
}
// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only
// the variants whose DAWG is present in the directory, under the given version.
func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
dir := t.TempDir()
copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present
reg := NewRegistry()
defer func() { _ = reg.Close() }()
loaded, err := reg.LoadAvailable(dir, "v2")
if err != nil {
t.Fatalf("load available: %v", err)
}
if len(loaded) != 1 || loaded[0] != VariantEnglish {
t.Fatalf("loaded = %v, want [scrabble_en]", loaded)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("scrabble_en v2 solver: %v", err)
}
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("scrabble_ru v2 should be absent: got %v", err)
}
}
// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot
// version plus every version subdirectory, the subdir version becoming the
// variant's latest while the boot version stays resident.
func TestOpenWithVersionsScansSubdirs(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() { // flat boot version: all three variants
copyDawg(t, testDictDir(), dir, v)
}
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("open with versions: %v", err)
}
defer func() { _ = reg.Close() }()
for _, v := range Variants() {
if _, err := reg.Solver(v, "v1"); err != nil {
t.Errorf("boot solver %s/v1: %v", v, err)
}
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Errorf("scrabble_en versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest scrabble_en: %v", err)
}
if latest != "v2" {
t.Errorf("latest scrabble_en = %q, want v2", latest)
}
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
t.Errorf("scrabble_ru versions = %v, want one (no v2 file)", got)
}
}
// TestOpenWithVersionsSkipsDotDirs verifies the boot scan ignores dot-prefixed
// subdirectories (the upload staging area), so a half-extracted archive there
// never becomes a resident version.
func TestOpenWithVersionsSkipsDotDirs(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
copyDawg(t, testDictDir(), filepath.Join(dir, ".staging"), VariantEnglish)
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("open with versions: %v", err)
}
defer func() { _ = reg.Close() }()
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("scrabble_en versions = %v, want only [v1] (.staging skipped)", got)
}
}
// TestOpenWithVersionsRecordsSeedMarker verifies the first boot records the seed
// version in the flat dir's marker, the marker is not mistaken for a version, and a
// reboot at the same seed version succeeds.
func TestOpenWithVersionsRecordsSeedMarker(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("first open: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want only [v1] (marker not a version)", got)
}
_ = reg.Close()
data, err := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if err != nil {
t.Fatalf("read seed marker: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Fatalf("seed marker = %q, want v1", got)
}
reg2, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("reboot at same seed: %v", err)
}
_ = reg2.Close()
}
// TestOpenWithVersionsMarkerWinsOverBoot verifies the recorded .seed_version marker
// is authoritative: once a directory is seeded, a different bootVersion
// (BACKEND_DICT_VERSION) is ignored — the flat dir keeps its recorded label — so a
// bumped build seed on a live volume cannot relabel the already-seeded bytes.
func TestOpenWithVersionsMarkerWinsOverBoot(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg, err := OpenWithVersions(dir, "v1") // seeds the marker = v1
if err != nil {
t.Fatalf("seed open: %v", err)
}
_ = reg.Close()
// Reboot with a bumped boot version: the marker (v1) wins, no error, v2 ignored.
reg2, err := OpenWithVersions(dir, "v2")
if err != nil {
t.Fatalf("reboot with bumped boot version: %v", err)
}
defer func() { _ = reg2.Close() }()
if got := reg2.Versions(VariantEnglish); len(got) != 1 || got[0] != "v1" {
t.Errorf("versions = %v, want [v1] (marker wins, v2 ignored)", got)
}
if _, err := reg2.Solver(VariantEnglish, "v2"); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("v2 must not be resident: got %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, seedMarkerFile))
if got := strings.TrimSpace(string(data)); got != "v1" {
t.Errorf("marker = %q, want v1 (unchanged)", got)
}
}
// TestOpenWithVersionsBumpedBootKeepsSubdir mirrors the live-contour case: a volume
// seeded as v1 with a v2 subdirectory (uploaded via the console), booted with a bumped
// build seed bootVersion=v2. The marker (v1) wins for the flat dir, and the v2
// subdirectory is still loaded — not skipped as "the boot version" — so both versions
// stay resident. (Skipping it would silently leave only the flat v1 bytes under v2.)
func TestOpenWithVersionsBumpedBootKeepsSubdir(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() {
copyDawg(t, testDictDir(), dir, v)
}
reg0, err := OpenWithVersions(dir, "v1") // seed marker = v1
if err != nil {
t.Fatalf("seed: %v", err)
}
_ = reg0.Close()
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // console upload
reg, err := OpenWithVersions(dir, "v2") // bumped build seed
if err != nil {
t.Fatalf("boot v2: %v", err)
}
defer func() { _ = reg.Close() }()
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("flat v1 must stay resident: %v", err)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("v2 subdir must be resident (not skipped): %v", err)
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) {
reg, err := Open(testDictDir(), "v1", VariantEnglish)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = reg.Close() }()
if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil {
t.Fatalf("reload v2: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Fatalf("versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest: %v", err)
}
if latest != "v2" {
t.Errorf("latest = %q, want v2", latest)
}
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("v1 still resident: %v", err)
}
}