a5db10c46e
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
The dictionary release moved to v1.2.1 while DICT_VERSION stayed pinned at v1.0.0 in CI and the image/compose seed defaults. Two problems: 1. CI validated against a stale dictionary. 2. The contour seed could be bumped on a live volume, which silently relabels the already-seeded bytes — voiding games pinned to the prior label and serving the wrong dictionary for new ones. The flat DAWGs carry no embedded version, so this drift was undetectable. Changes: - Seed-drift guard: OpenWithVersions records the flat dir's version in a .seed_version marker on first boot and refuses to start when a later BACKEND_DICT_VERSION disagrees. DICT_VERSION is now the seed for a *fresh* volume only; a live contour migrates through the admin console (old versions stay resident, in-progress games keep replaying). - Track the current release: CI's DICT_VERSION centralised to one workflow-level env (v1.2.1); image/compose/.env seed defaults bumped to v1.2.1. The deploy job keeps reading the per-contour vars.TEST_DICT_VERSION. - Docs: ARCHITECTURE §5 (decision record), backend/deploy READMEs, PRERELEASE tracker (DV row). Verified locally against the v1.2.1 artifact: gofmt, build, vet, unit and integration (-tags=integration) all green.
195 lines
6.0 KiB
Go
195 lines
6.0 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()
|
|
}
|
|
|
|
// TestOpenWithVersionsRejectsSeedDrift verifies the guard refuses to boot a directory
|
|
// seeded as one version when BACKEND_DICT_VERSION (bootVersion) names another — the
|
|
// seed-drift footgun a bumped build seed on a live volume would cause.
|
|
func TestOpenWithVersionsRejectsSeedDrift(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("seed open: %v", err)
|
|
}
|
|
_ = reg.Close()
|
|
|
|
if _, err := OpenWithVersions(dir, "v2"); err == nil {
|
|
t.Fatal("open after seed bump: want error, got nil")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|