clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / format / bundle / bundle_test.go
bundle_test.go 10.9K
1package bundle_test23import (4 "archive/zip"5 "bytes"6 "os"7 "path/filepath"8 "strings"9 "testing"10 "time"1112 "github.com/FelineStateMachine/atlas/format/bundle"13)1415func soundManifest() bundle.Manifest {16 return bundle.Manifest{17 Format: bundle.Format,18 FormatVersion: bundle.FormatVersion,19 Volume: bundle.Volume{Slug: "fixture", Title: "Fixture"},20 Version: bundle.Version{Stamp: "abc", CreatedAt: "2026-01-01T00:00:00Z"},21 Worlds: []bundle.WorldEntry{{Slug: "overworld", Title: "Overworld"}},22 }23}2425func TestManifestValidation(t *testing.T) {26 if err := soundManifest().Validate(); err != nil {27 t.Fatalf("a sound manifest is refused: %v", err)28 }29 cases := map[string]func(*bundle.Manifest){30 "a container it does not name": func(m *bundle.Manifest) { m.Format = "zip-of-things" },31 "a format version from ahead": func(m *bundle.Manifest) { m.FormatVersion = 99 },32 "a format version from behind": func(m *bundle.Manifest) { m.FormatVersion = 2 },33 "no volume slug": func(m *bundle.Manifest) { m.Volume.Slug = "" },34 "a volume slug that climbs": func(m *bundle.Manifest) { m.Volume.Slug = "../escape" },35 "a volume slug in caps": func(m *bundle.Manifest) { m.Volume.Slug = "Fallout76" },36 "a volume slug with a space": func(m *bundle.Manifest) { m.Volume.Slug = "fall out" },37 "a volume slug leading a dash": func(m *bundle.Manifest) { m.Volume.Slug = "-fixture" },38 "no volume title": func(m *bundle.Manifest) { m.Volume.Title = "" },39 "no stamp": func(m *bundle.Manifest) { m.Version.Stamp = "" },40 "no creation time": func(m *bundle.Manifest) { m.Version.CreatedAt = "" },41 "no worlds": func(m *bundle.Manifest) { m.Worlds = nil },42 "a world slug with a separator": func(m *bundle.Manifest) { m.Worlds[0].Slug = "a/b" },43 "an untitled world": func(m *bundle.Manifest) { m.Worlds[0].Title = "" },44 "a world listed twice": func(m *bundle.Manifest) { m.Worlds = append(m.Worlds, m.Worlds[0]) },45 }46 for name, corrupt := range cases {47 t.Run(name, func(t *testing.T) {48 manifest := soundManifest()49 corrupt(&manifest)50 if err := manifest.Validate(); err == nil {51 t.Errorf("a manifest with %s is accepted", name)52 }53 })54 }55}5657func TestWorldEntryCountsFeatures(t *testing.T) {58 entry := bundle.WorldEntry{Points: 45, Paths: 5, Areas: 16}59 if got := entry.Features(); got != 66 {60 t.Errorf("Features = %d, want 66", got)61 }62}6364func TestWriterRefusesUnsafeAndRepeatedEntries(t *testing.T) {65 writer, err := bundle.NewWriter(&bytes.Buffer{}, soundManifest())66 if err != nil {67 t.Fatal(err)68 }69 for _, name := range []string{70 "", "/etc/hosts", "worlds/../escape", "tiles/", "worlds//a.json",71 "./worlds/a.json", bundle.ManifestName,72 } {73 if err := writer.AddDeflated(name, []byte("x")); err == nil {74 t.Errorf("entry %q is accepted", name)75 }76 }77 if err := writer.AddDeflated("worlds/overworld.json", []byte("{}")); err != nil {78 t.Fatal(err)79 }80 if err := writer.AddDeflated("worlds/overworld.json", []byte("{}")); err == nil {81 t.Error("the same entry is accepted twice")82 }83}8485func TestWriterRefusesAManifestAReaderWouldRefuse(t *testing.T) {86 manifest := soundManifest()87 manifest.Version.Stamp = ""88 if _, err := bundle.NewWriter(&bytes.Buffer{}, manifest); err == nil {89 t.Error("a stampless manifest started a bundle")90 }91}9293// Tiles and packed locations are stored uncompressed so a server can answer a94// byte range straight out of the archive; payload JSON is deflated because it95// is read whole and shrinks severalfold.96func TestWriterStoresWhatIsServedByRange(t *testing.T) {97 reader := fixture{}.open(t, t.TempDir())98 stored := map[string]bool{99 bundle.WorldEntryName("overworld", bundle.PackedSuffix): true,100 bundle.TilesPrefix + "overworld/0/0/0.jpg": true,101 bundle.WorldEntryName("overworld", bundle.WorldSuffix): false,102 bundle.WorldEntryName("overworld", bundle.TextSuffix): false,103 bundle.ManifestName: false,104 }105 for name, want := range stored {106 if got := reader.Stored(name); got != want {107 t.Errorf("%s stored = %v, want %v", name, got, want)108 }109 }110 if names := reader.Names(); len(names) == 0 || names[0] != bundle.ManifestName {111 t.Errorf("the archive does not lead with the manifest: %v", names)112 }113}114115func TestOpenRoundTripsWhatAWriterWrote(t *testing.T) {116 reader := fixture{117 slug: "fixture",118 stamp: "deadbeefcafe0000",119 worlds: []fixtureWorld{{120 slug: "overworld",121 locations: []bundle.Location{122 {ID: 1, Title: "Origin", Lat: 12.5, Lng: -3.25},123 {ID: 2, Title: "Peak", Lat: 80, Lng: 170},124 },125 }},126 }.open(t, t.TempDir())127128 if reader.Manifest.Volume.Slug != "fixture" {129 t.Errorf("volume slug = %q", reader.Manifest.Volume.Slug)130 }131 if got := reader.Manifest.Worlds[0].Points; got != 2 {132 t.Errorf("point count = %d, want 2", got)133 }134 if err := reader.Validate(); err != nil {135 t.Errorf("a fixture bundle fails validation: %v", err)136 }137138 packed, err := reader.Locations("overworld")139 if err != nil {140 t.Fatal(err)141 }142 if packed.Len() != 2 || packed.Title(0) != "Origin" || packed.Title(1) != "Peak" {143 t.Fatalf("locations round-tripped as %+v", packed.All())144 }145 if packed.Lat(0) != 12.5 || packed.Lng(0) != -3.25 {146 t.Errorf("Origin landed at %v,%v", packed.Lat(0), packed.Lng(0))147 }148149 source, size, err := reader.OpenEntry(bundle.TilesPrefix + "overworld/0/0/0.jpg")150 if err != nil {151 t.Fatal(err)152 }153 defer source.Close()154 if size <= 0 {155 t.Errorf("tile size = %d", size)156 }157 if _, _, err := reader.OpenEntry("tiles/nowhere.jpg"); err == nil {158 t.Error("an absent entry opened")159 }160}161162// A reader made over bytes owns nothing and can be handed a buffer, a mapped163// file, or an object store's ranged reader. It is what keeps the package free164// of any particular filesystem.165func TestNewReaderWorksOverBytes(t *testing.T) {166 path := fixture{slug: "fixture"}.build(t, t.TempDir())167 data, err := os.ReadFile(path)168 if err != nil {169 t.Fatal(err)170 }171 reader, err := bundle.NewReader(bytes.NewReader(data), int64(len(data)))172 if err != nil {173 t.Fatal(err)174 }175 if reader.Manifest.Volume.Slug != "fixture" {176 t.Errorf("volume slug = %q", reader.Manifest.Volume.Slug)177 }178 if reader.Path != "" {179 t.Errorf("a reader over bytes claims path %q", reader.Path)180 }181 if err := reader.Close(); err != nil {182 t.Errorf("closing a reader that owns nothing: %v", err)183 }184 if reader.Unchanged(int64(len(data)), time0()) {185 t.Error("a reader over bytes claims a file did not change")186 }187}188189func TestOpenRefusesWhatIsNotABundle(t *testing.T) {190 dir := t.TempDir()191192 plain := filepath.Join(dir, "plain.atlas")193 if err := os.WriteFile(plain, []byte("not a zip at all"), 0o644); err != nil {194 t.Fatal(err)195 }196 if _, err := bundle.Open(plain); err == nil {197 t.Error("a file that is not an archive is accepted")198 }199 if _, err := bundle.Open(filepath.Join(dir, "absent.atlas")); err == nil {200 t.Error("a file that is not there is accepted")201 }202203 // A real zip with no manifest is somebody's archive wearing our suffix.204 bare := filepath.Join(dir, "bare.atlas")205 if err := os.WriteFile(bare, archiveWithout(t), 0o644); err != nil {206 t.Fatal(err)207 }208 if _, err := bundle.Open(bare); err == nil || !strings.Contains(err.Error(), bundle.ManifestName) {209 t.Errorf("a zip without a manifest is refused with %v, wanting a complaint about %s", err, bundle.ManifestName)210 }211}212213// An unknown format version is refused outright, which is the one place the214// reader is strict. Everything else about a newer bundle it would happily215// ignore; a layout it does not know it cannot.216func TestOpenRefusesAFormatVersionItDoesNotKnow(t *testing.T) {217 dir := t.TempDir()218 path := filepath.Join(dir, "future.atlas")219 file, err := os.Create(path)220 if err != nil {221 t.Fatal(err)222 }223 archive := zip.NewWriter(file)224 entry, err := archive.Create(bundle.ManifestName)225 if err != nil {226 t.Fatal(err)227 }228 if _, err := entry.Write([]byte(`{"format":"atlas-bundle","formatVersion":4,` +229 `"volume":{"slug":"future","title":"Future"},` +230 `"version":{"stamp":"aa","createdAt":"2026-01-01T00:00:00Z"},` +231 `"worlds":[{"slug":"overworld","title":"Overworld"}]}`)); err != nil {232 t.Fatal(err)233 }234 must(t, archive.Close())235 must(t, file.Close())236237 if _, err := bundle.Open(path); err == nil || !strings.Contains(err.Error(), "format version") {238 t.Errorf("a version-4 bundle opened with %v", err)239 }240}241242func TestOpenRefusesAnImplausibleManifest(t *testing.T) {243 dir := t.TempDir()244 path := filepath.Join(dir, "huge.atlas")245 file, err := os.Create(path)246 if err != nil {247 t.Fatal(err)248 }249 archive := zip.NewWriter(file)250 entry, err := archive.Create(bundle.ManifestName)251 if err != nil {252 t.Fatal(err)253 }254 if _, err := entry.Write(bytes.Repeat([]byte(" "), bundle.MaxManifestSize+1)); err != nil {255 t.Fatal(err)256 }257 must(t, archive.Close())258 must(t, file.Close())259260 if _, err := bundle.Open(path); err == nil || !strings.Contains(err.Error(), "implausibly large") {261 t.Errorf("an oversized manifest opened with %v", err)262 }263}264265func TestMarshalManifestIsTheEncodingThatCounts(t *testing.T) {266 manifest := soundManifest()267 manifest.Conventions = 2268 data, err := bundle.MarshalManifest(manifest)269 if err != nil {270 t.Fatal(err)271 }272 want := `{"format":"atlas-bundle","formatVersion":3,"conventions":2,` +273 `"volume":{"slug":"fixture","title":"Fixture"},` +274 `"version":{"stamp":"abc","createdAt":"2026-01-01T00:00:00Z"},` +275 `"tileGrid":{"sourceZoom":0,"firstTile":0,"tileSize":0,"size":0},` +276 `"worlds":[{"slug":"overworld","title":"Overworld","center":{"lat":0,"lng":0},` +277 `"points":0,"paths":0,"areas":0,"updatedAt":""}]}`278 if string(data) != want {279 t.Errorf("manifest encoded as\n%s\nwant\n%s", data, want)280 }281282 // Conventions and revision are the two fields a bundle may leave out; an283 // undeclaring bundle must not carry them at all.284 plain := soundManifest()285 data, err = bundle.MarshalManifest(plain)286 if err != nil {287 t.Fatal(err)288 }289 if strings.Contains(string(data), "conventions") || strings.Contains(string(data), "revision") {290 t.Errorf("an undeclaring manifest carries them anyway: %s", data)291 }292}293294func TestWorldEntryName(t *testing.T) {295 cases := map[string]string{296 bundle.WorldSuffix: "worlds/overworld.json",297 bundle.PackedSuffix: "worlds/overworld.bin",298 bundle.TextSuffix: "worlds/overworld.text",299 }300 for suffix, want := range cases {301 if got := bundle.WorldEntryName("overworld", suffix); got != want {302 t.Errorf("WorldEntryName(overworld, %q) = %q, want %q", suffix, got, want)303 }304 }305}306307// archiveWithout builds a genuine zip holding a single unrelated entry,308// exercising the reader against an archive that is not a bundle.309func archiveWithout(t *testing.T) []byte {310 t.Helper()311 var buffer bytes.Buffer312 archive := zip.NewWriter(&buffer)313 entry, err := archive.Create("README.txt")314 if err != nil {315 t.Fatal(err)316 }317 if _, err := entry.Write([]byte("just an archive")); err != nil {318 t.Fatal(err)319 }320 must(t, archive.Close())321 return buffer.Bytes()322}323324// time0 is a zero modification time, for the one assertion that a reader over325// bytes never claims to know a file.326func time0() time.Time { return time.Time{} }