1package oshost_test23import (4 "archive/zip"5 "bytes"6 "context"7 "errors"8 "os"9 "path/filepath"10 "strings"11 "testing"1213 "github.com/FelineStateMachine/atlas/format/vnext"14 "github.com/FelineStateMachine/atlas/internal/app/hostenv"15 "github.com/FelineStateMachine/atlas/internal/app/hostenv/oshost"16)1718// build writes a real bundle through the real writer, so the store under test19// meets an archive the format would accept and nothing opaque is checked in.20type build struct {21 slug string22 title string23 createdAt string24 revision int25 world string26}2728func (b build) write(t *testing.T, dir string) string {29 t.Helper()30 if b.world == "" {31 b.world = "overworld"32 }33 volume := vnext.Volume{34 ID: b.slug, Title: b.title,35 Worlds: []vnext.World{{36 ID: b.world, Title: b.title + " ground",37 CoordinateSpace: vnext.CoordinateSpace{ID: "space", Kind: "synthetic", Unit: "pixel", Definition: "atlas:plane", SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192, Extent: [4]float64{0, 0, 8192, 8192}},38 FeatureSets: []vnext.FeatureSet{{ID: "markers", Title: "Markers", SemanticType: "geometry.point", Features: []vnext.Feature{{39 ID: "origin", Title: "Origin", Geometry: vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{0, 0}}}}}},40 }}}},41 RasterPyramids: []vnext.RasterPyramid{{ID: "base", Name: "Base", Codec: "image/jpeg", TileSize: 256, MinZoom: 0, MaxZoom: 0, FullZoom: 0, SourceZoom: 13, Template: "tiles/" + b.world + "/{z}/{x}/{y}.jpg", Formats: []string{"jpg"}}},42 Presentation: vnext.Presentation{ID: "default", Title: "Default", Styles: []vnext.Style{{ID: "marker"}}, Layers: []vnext.Layer{{ID: "markers", FeatureSet: "markers", Style: "marker", Visible: true}}},43 }},44 }45 packed, err := vnext.Compile(volume)46 if err != nil {47 t.Fatal(err)48 }49 packed.Release.CreatedAt = b.createdAt50 packed.Release.Revision = b.revision51 packed.Blobs = append(packed.Blobs, vnext.Blob{Name: "tiles/" + b.world + "/0/0/0.jpg", Data: []byte("raster")})52 path := filepath.Join(dir, b.slug+"-"+b.createdAt[5:7]+b.createdAt[8:10]+".atlas")53 file, err := os.Create(path)54 if err != nil {55 t.Fatal(err)56 }57 if err := vnext.Write(file, packed); err != nil {58 file.Close()59 t.Fatal(err)60 }61 if err := file.Close(); err != nil {62 t.Fatal(err)63 }64 return path65}6667// The fold is format/vnext's; what this checks is that the walk hands it68// every build and serves the one it picks.69func TestVolumesServesTheFoldsWinner(t *testing.T) {70 dir := t.TempDir()71 build{slug: "tunic", title: "TUNIC", createdAt: "2026-01-01T00:00:00Z"}.write(t, dir)72 newer := build{slug: "tunic", title: "TUNIC", createdAt: "2026-02-01T00:00:00Z"}73 newer.write(t, dir)74 build{slug: "mars", title: "Mars", createdAt: "2026-01-15T00:00:00Z", world: "global"}.write(t, dir)7576 store, err := oshost.NewVolumes(dir)77 if err != nil {78 t.Fatal(err)79 }80 t.Cleanup(func() { store.Close() })8182 volumes := store.Volumes()83 if len(volumes) != 2 {84 t.Fatalf("%d volumes serving, want 2", len(volumes))85 }86 if got := volumes[0].Info().Slug; got != "mars" {87 t.Errorf("volumes are listed %q first, want them sorted by slug", got)88 }89 tunic := volumes[1].Info()90 if tunic.Release.CreatedAt != "2026-02-01T00:00:00Z" {91 t.Errorf("serving the build of %s, want the newest capture", tunic.Release.CreatedAt)92 }93 if len(tunic.Worlds) != 1 || tunic.Worlds[0].Points != 1 {94 t.Fatalf("page-zero feature counts = %#v, want one point", tunic.Worlds)95 }9697 payload, err := volumes[1].Blob("tiles/overworld/0/0/0.jpg")98 if err != nil {99 t.Fatal(err)100 }101 if string(payload) != "raster" {102 t.Errorf("blob = %q", payload)103 }104 if _, err := volumes[1].Blob("worlds/not-a-world.json"); err == nil {105 t.Error("an entry the bundle does not hold opened")106 }107}108109func TestVolumesPageZeroDoesNotReadFeaturePartitions(t *testing.T) {110 dir := t.TempDir()111 path := build{slug: "sample", title: "Sample", createdAt: "2026-01-01T00:00:00Z"}.write(t, dir)112 data, err := os.ReadFile(path)113 if err != nil {114 t.Fatal(err)115 }116 archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))117 if err != nil {118 t.Fatal(err)119 }120 corrupted := false121 for _, entry := range archive.File {122 if !strings.HasSuffix(entry.Name, ".features.pack") {123 continue124 }125 offset, err := entry.DataOffset()126 if err != nil {127 t.Fatal(err)128 }129 data[offset+int64(entry.UncompressedSize64)-1] ^= 0xff130 corrupted = true131 break132 }133 if !corrupted {134 t.Fatal("fixture has no physical feature partition")135 }136 if err := os.WriteFile(path, data, 0o644); err != nil {137 t.Fatal(err)138 }139140 store, err := oshost.NewVolumes(dir)141 if err != nil {142 t.Fatalf("page zero read a feature partition: %v", err)143 }144 t.Cleanup(func() { store.Close() })145 volumes := store.Volumes()146 if len(volumes) != 1 || len(volumes[0].Outline().Worlds) != 1 {147 t.Fatalf("outline did not open: %#v", volumes)148 }149 if _, err := volumes[0].FeaturePage(vnext.FeaturePageRequest{FeatureSet: "markers", Limit: 1}); err == nil {150 t.Fatal("demanded corrupt feature partition was accepted")151 }152}153154func TestVolumesRescanReportsWhatMoved(t *testing.T) {155 dir := t.TempDir()156 build{slug: "tunic", title: "TUNIC", createdAt: "2026-01-01T00:00:00Z"}.write(t, dir)157 store, err := oshost.NewVolumes(dir)158 if err != nil {159 t.Fatal(err)160 }161 t.Cleanup(func() { store.Close() })162163 if changed, err := store.Rescan(); err != nil || len(changed) != 0 {164 t.Fatalf("a rescan over an unchanged library reported %v, %v", changed, err)165 }166167 build{slug: "mars", title: "Mars", createdAt: "2026-01-15T00:00:00Z", world: "global"}.write(t, dir)168 changed, err := store.Rescan()169 if err != nil {170 t.Fatal(err)171 }172 if len(changed) != 1 || changed[0] != "mars" {173 t.Fatalf("rescan reported %v, want just the arrival", changed)174 }175}176177func TestVolumesInstall(t *testing.T) {178 library := t.TempDir()179 elsewhere := t.TempDir()180 source := build{slug: "tunic", title: "TUNIC", createdAt: "2026-01-01T00:00:00Z"}.write(t, elsewhere)181182 store, err := oshost.NewVolumes(library)183 if err != nil {184 t.Fatal(err)185 }186 t.Cleanup(func() { store.Close() })187 if len(store.Volumes()) != 0 {188 t.Fatal("an empty library is not empty")189 }190191 content, err := os.Open(source)192 if err != nil {193 t.Fatal(err)194 }195 installed, err := store.Install(filepath.Base(source), content)196 content.Close()197 if err != nil {198 t.Fatal(err)199 }200 if installed.Slug != "tunic" || installed.Already {201 t.Errorf("install reported %+v, want a first arrival of tunic", installed)202 }203 if len(installed.Changed) != 1 || installed.Changed[0] != "tunic" {204 t.Errorf("install reported %v changed, want tunic", installed.Changed)205 }206 if len(store.Volumes()) != 1 {207 t.Error("the import did not rescan")208 }209210 // The same build again: a successful import that copies nothing and211 // changes nothing, because the file name carries the stamp.212 content, err = os.Open(source)213 if err != nil {214 t.Fatal(err)215 }216 again, err := store.Install(filepath.Base(source), content)217 content.Close()218 if err != nil {219 t.Fatal(err)220 }221 if !again.Already || len(again.Changed) != 0 {222 t.Errorf("reinstalling the same build reported %+v, want a no-op", again)223 }224225 if _, err := store.Install("junk.atlas", bytes.NewReader([]byte("not a zip"))); err == nil {226 t.Error("a file that is not a bundle was let into the library")227 }228 if entries, _ := os.ReadDir(library); len(entries) != 1 {229 t.Errorf("the library holds %d files after a refused import, want 1", len(entries))230 }231}232233func TestSessionsRoundTrip(t *testing.T) {234 dir := filepath.Join(t.TempDir(), "sessions")235 store, err := oshost.NewSessions(dir)236 if err != nil {237 t.Fatal(err)238 }239240 if _, err := store.Load("volume.tunic.json"); !errors.Is(err, hostenv.ErrNoSession) {241 t.Fatalf("loading a record nobody wrote = %v, want ErrNoSession", err)242 }243 if err := store.Save("volume.tunic.json", []byte(`{"world":"world"}`)); err != nil {244 t.Fatal(err)245 }246 if err := store.Save("volume.tunic.json", []byte(`{"world":"other"}`)); err != nil {247 t.Fatal(err)248 }249 held, err := store.Load("volume.tunic.json")250 if err != nil || string(held) != `{"world":"other"}` {251 t.Fatalf("Load = %q, %v, want the second write whole", held, err)252 }253254 names, err := store.Names()255 if err != nil {256 t.Fatal(err)257 }258 if len(names) != 1 || names[0] != "volume.tunic.json" {259 t.Fatalf("Names = %v", names)260 }261 if err := store.Save("../escape.json", nil); err == nil {262 t.Error("a name that climbs out of the directory was accepted")263 }264}265266// Deleting a record, which is the whole of the blunt reset: the arrangement a267// volume opens with is synthesized from the world itself, so a record that is268// gone is a volume that comes back fresh.269//270// The half of the contract easiest to get wrong is the second one. A record271// that is not there is already what the caller asked for, so a missing file is272// success -- otherwise a reader who pressed reset twice would meet an error273// about a file they were right to want gone.274func TestSessionsDelete(t *testing.T) {275 dir := filepath.Join(t.TempDir(), "sessions")276 store, err := oshost.NewSessions(dir)277 if err != nil {278 t.Fatal(err)279 }280281 if err := store.Delete("volume.tunic.json"); err != nil {282 t.Errorf("deleting a record nobody wrote = %v, want a quiet success", err)283 }284 if err := store.Save("volume.tunic.json", []byte(`{"world":"world"}`)); err != nil {285 t.Fatal(err)286 }287 if err := store.Save("app.json", []byte(`{"volume":"tunic"}`)); err != nil {288 t.Fatal(err)289 }290 if err := store.Delete("volume.tunic.json"); err != nil {291 t.Fatal(err)292 }293 if _, err := store.Load("volume.tunic.json"); !errors.Is(err, hostenv.ErrNoSession) {294 t.Errorf("the record was still readable after a delete: %v", err)295 }296 if _, err := os.Stat(filepath.Join(dir, "volume.tunic.json")); !errors.Is(err, os.ErrNotExist) {297 t.Errorf("the record's file is still on disk: %v", err)298 }299 // One record went, not the directory: which volume the reader was last in300 // is a different fact, and it is nobody's to take away here.301 names, err := store.Names()302 if err != nil {303 t.Fatal(err)304 }305 if len(names) != 1 || names[0] != "app.json" {306 t.Errorf("Names = %v, want the pointer left standing", names)307 }308 if err := store.Delete("volume.tunic.json"); err != nil {309 t.Errorf("deleting a record twice = %v, want a quiet success", err)310 }311 if err := store.Delete("../escape.json"); err == nil {312 t.Error("a name that climbs out of the directory was accepted")313 }314}315316func TestHeadlessHostCannotPickAFile(t *testing.T) {317 host, err := oshost.New(oshost.Options{BundlesDir: t.TempDir()})318 if err != nil {319 t.Fatal(err)320 }321 if _, _, err := host.PickFile(context.Background()); !errors.Is(err, hostenv.ErrNotAvailable) {322 t.Fatalf("PickFile on a host with no window = %v, want ErrNotAvailable", err)323 }324 if _, err := host.Sessions().Names(); err != nil {325 t.Fatalf("a host with no session directory has no sessions, not an error: %v", err)326 }327}