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) } }