1package bundle_test23import (4 "encoding/json"5 "os"6 "path/filepath"7 "slices"8 "testing"9 "time"1011 "github.com/FelineStateMachine/atlas/internal/bundle"12 "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"13)1415func decodeCatalog(t *testing.T, snapshot *bundle.Snapshot) []map[string]any {16 t.Helper()17 var catalog struct {18 Volumes []map[string]any `json:"volumes"`19 }20 if err := json.Unmarshal(snapshot.Catalog, &catalog); err != nil {21 t.Fatal(err)22 }23 return catalog.Volumes24}2526func TestRegistryAnswersEmptyForAMissingDirectory(t *testing.T) {27 registry := bundle.NewRegistry(filepath.Join(t.TempDir(), "never-created"))28 if err := registry.Rescan(); err != nil {29 t.Fatalf("a missing directory is an error: %v", err)30 }31 if games := decodeCatalog(t, registry.Snapshot()); len(games) != 0 {32 t.Fatalf("games = %v, want none", games)33 }34}3536func TestRegistryComposesTheCatalogSortedByTitle(t *testing.T) {37 dir := t.TempDir()38 bundletest.Build(t, dir, bundletest.Spec{Slug: "zebra-quest", Title: "Zebra Quest"})39 bundletest.Build(t, dir, bundletest.Spec{Slug: "aardvark-saga", Title: "Aardvark Saga"})4041 registry := bundle.NewRegistry(dir)42 if err := registry.Rescan(); err != nil {43 t.Fatal(err)44 }45 games := decodeCatalog(t, registry.Snapshot())46 if len(games) != 2 || games[0]["title"] != "Aardvark Saga" || games[1]["title"] != "Zebra Quest" {47 t.Fatalf("games = %v, want Aardvark Saga then Zebra Quest", games)48 }4950 stamp, _ := games[0]["stamp"].(string)51 base, _ := games[0]["base"].(string)52 want := bundle.BasePath + "/aardvark-saga/" + bundle.ShortStamp(stamp)53 if base != want {54 t.Errorf("base = %q, want %q", base, want)55 }56 if _, ok := games[0]["tileGrid"].(map[string]any); !ok {57 t.Error("a game carries no tile grid of its own")58 }59}6061func TestRegistryLetsTheNewestBundleOfAGameWin(t *testing.T) {62 dir := t.TempDir()63 bundletest.Build(t, dir, bundletest.Spec{Slug: "game", Title: "Old Title", CreatedAt: "2026-01-01T00:00:00Z"})64 // A second file for the same game, as a versioned download would land.65 sideBySide := bundletest.Build(t, t.TempDir(), bundletest.Spec{66 Slug: "game", Title: "New Title", CreatedAt: "2026-06-01T00:00:00Z",67 })68 moved := filepath.Join(dir, "game-v2.atlas")69 if err := os.Rename(sideBySide, moved); err != nil {70 t.Fatal(err)71 }7273 registry := bundle.NewRegistry(dir)74 if err := registry.Rescan(); err != nil {75 t.Fatal(err)76 }77 snapshot := registry.Snapshot()78 if len(snapshot.Volumes) != 1 {79 t.Fatalf("games = %d, want the two files folded into one", len(snapshot.Volumes))80 }81 if won := snapshot.Volumes["game"].Manifest.Volume.Title; won != "New Title" {82 t.Errorf("the winning bundle is %q, want the newer one", won)83 }84}8586func TestRegistrySurvivesABundleThatIsNotOne(t *testing.T) {87 dir := t.TempDir()88 bundletest.Build(t, dir, bundletest.Spec{Slug: "sound", Title: "Sound"})89 if err := os.WriteFile(filepath.Join(dir, "broken.atlas"), []byte("not a zip"), 0o644); err != nil {90 t.Fatal(err)91 }9293 registry := bundle.NewRegistry(dir)94 if err := registry.Rescan(); err != nil {95 t.Fatalf("one broken file fails the scan: %v", err)96 }97 if games := decodeCatalog(t, registry.Snapshot()); len(games) != 1 {98 t.Fatalf("games = %v, want the sound one alone", games)99 }100}101102func TestRescanSeesArrivalsUpdatesAndDepartures(t *testing.T) {103 dir := t.TempDir()104 registry := bundle.NewRegistry(dir)105 if err := registry.Rescan(); err != nil {106 t.Fatal(err)107 }108109 // Arrival.110 path := bundletest.Build(t, dir, bundletest.Spec{Slug: "game", Title: "First", CreatedAt: "2026-01-01T00:00:00Z"})111 if err := registry.Rescan(); err != nil {112 t.Fatal(err)113 }114 before := registry.Snapshot()115 if before.Volumes["game"].Manifest.Volume.Title != "First" {116 t.Fatal("an arriving bundle is not seen")117 }118119 // An untouched file keeps its open bundle across rescans.120 if err := registry.Rescan(); err != nil {121 t.Fatal(err)122 }123 if registry.Snapshot().Volumes["game"] != before.Volumes["game"] {124 t.Error("an untouched file was reopened")125 }126127 // Update in place, as a downloaded replacement would land.128 replacement := bundletest.Build(t, t.TempDir(), bundletest.Spec{129 Slug: "game", Title: "Second", CreatedAt: "2026-06-01T00:00:00Z",130 })131 if err := os.Rename(replacement, path); err != nil {132 t.Fatal(err)133 }134 if err := registry.Rescan(); err != nil {135 t.Fatal(err)136 }137 after := registry.Snapshot()138 if after.Volumes["game"].Manifest.Volume.Title != "Second" {139 t.Fatal("a replaced file still serves its old content")140 }141 // The old snapshot still reads until its grace ends, so a request that142 // loaded it before the swap finishes cleanly.143 if _, err := before.Volumes["game"].ReadEntry("worlds/overworld.json"); err != nil {144 t.Errorf("the retired bundle is already unreadable: %v", err)145 }146147 // Departure.148 if err := os.Remove(path); err != nil {149 t.Fatal(err)150 }151 if err := registry.Rescan(); err != nil {152 t.Fatal(err)153 }154 if games := decodeCatalog(t, registry.Snapshot()); len(games) != 0 {155 t.Fatalf("games = %v, want none after removal", games)156 }157}158159func TestRescanTellsItsListenerWhatChanged(t *testing.T) {160 dir := t.TempDir()161 registry := bundle.NewRegistry(dir)162 var told [][]string163 registry.SetOnChange(func(changed []string) { told = append(told, changed) })164165 // Arrival.166 path := bundletest.Build(t, dir, bundletest.Spec{Slug: "game", CreatedAt: "2026-01-01T00:00:00Z"})167 bundletest.Build(t, dir, bundletest.Spec{Slug: "other", CreatedAt: "2026-01-01T00:00:00Z"})168 if err := registry.Rescan(); err != nil {169 t.Fatal(err)170 }171 if len(told) != 1 || !slices.Equal(told[0], []string{"game", "other"}) {172 t.Fatalf("arrivals told as %v, want [[game other]]", told)173 }174175 // Nothing moved: nothing said.176 if err := registry.Rescan(); err != nil {177 t.Fatal(err)178 }179 if len(told) != 1 {180 t.Fatalf("an unchanged rescan spoke: %v", told)181 }182183 // A replacement and a departure, together.184 replacement := bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-06-01T00:00:00Z"})185 if err := os.Rename(replacement, path); err != nil {186 t.Fatal(err)187 }188 if err := os.Remove(filepath.Join(dir, "other.atlas")); err != nil {189 t.Fatal(err)190 }191 if err := registry.Rescan(); err != nil {192 t.Fatal(err)193 }194 if len(told) != 2 || !slices.Equal(told[1], []string{"game", "other"}) {195 t.Fatalf("changes told as %v, want the replaced and the departed", told)196 }197}198199func TestWatchSeesADroppedBundleOnItsOwn(t *testing.T) {200 dir := t.TempDir()201 registry := bundle.NewRegistry(dir)202 if err := registry.Rescan(); err != nil {203 t.Fatal(err)204 }205 if err := registry.Watch(t.Context()); err != nil {206 t.Fatal(err)207 }208209 // Assembled elsewhere and renamed in, the way the bundler and the210 // importer both deliver.211 staged := bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game"})212 if err := os.Rename(staged, filepath.Join(dir, "game.atlas")); err != nil {213 t.Fatal(err)214 }215216 deadline := time.After(5 * time.Second)217 for {218 if _, ok := registry.Snapshot().Volumes["game"]; ok {219 return220 }221 select {222 case <-deadline:223 t.Fatal("the watcher never noticed the bundle")224 case <-time.After(25 * time.Millisecond):225 }226 }227}228229func TestInstallCopiesTheSoundAndRefusesTheBroken(t *testing.T) {230 source := t.TempDir()231 sound := bundletest.Build(t, source, bundletest.Spec{Slug: "game", Title: "Game"})232 broken := filepath.Join(source, "broken.atlas")233 if err := os.WriteFile(broken, []byte("not a bundle"), 0o644); err != nil {234 t.Fatal(err)235 }236237 registry := bundle.NewRegistry(t.TempDir())238 installed, refused := registry.Install([]string{sound, broken})239 if !slices.Equal(installed, []string{"game"}) {240 t.Fatalf("installed = %v, want [game]", installed)241 }242 if len(refused) != 1 {243 t.Fatalf("refused = %v, want the one broken file", refused)244 }245 // The copy lands under the build's own versioned name, whatever the246 // source file was called, and the rescan has already made it servable.247 held, ok := registry.Snapshot().Volumes["game"]248 if !ok {249 t.Fatal("the installed game is not being served")250 }251 want := filepath.Join(registry.Dir(), bundle.VersionedFileName(held.Manifest))252 if held.Path != want {253 t.Errorf("installed at %s, want %s", held.Path, want)254 }255 if _, err := os.Stat(want); err != nil {256 t.Error(err)257 }258}259260// Importing a build older than the one installed must not send the library261// backwards: the old file arrives under its own name and sits shadowed.262func TestInstallingAnOlderBuildDoesNotDowngrade(t *testing.T) {263 registry := bundle.NewRegistry(t.TempDir())264 newer := bundletest.Build(t, t.TempDir(), bundletest.Spec{265 Slug: "game", Title: "Newer", CreatedAt: "2026-06-01T00:00:00Z",266 })267 older := bundletest.Build(t, t.TempDir(), bundletest.Spec{268 Slug: "game", Title: "Older", CreatedAt: "2026-01-01T00:00:00Z",269 })270271 if installed, refused := registry.Install([]string{newer}); len(installed) != 1 || len(refused) != 0 {272 t.Fatalf("installing the newer build: %v / %v", installed, refused)273 }274 if installed, refused := registry.Install([]string{older}); len(installed) != 1 || len(refused) != 0 {275 t.Fatalf("installing the older build: %v / %v", installed, refused)276 }277 if won := registry.Snapshot().Volumes["game"].Manifest.Volume.Title; won != "Newer" {278 t.Errorf("the library serves %q after an old import, want the newer build", won)279 }280}