clone

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

main
atlas / internal / generate / sources / nasabluemarble / nasabluemarble_test.go
nasabluemarble_test.go 9.9K
1package nasabluemarble23import (4 "encoding/json"5 "log/slog"6 "os"7 "path/filepath"8 "strconv"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/atlas/format/semconv"13 "github.com/FelineStateMachine/atlas/internal/generate/archive"14 "github.com/FelineStateMachine/atlas/internal/generate/doc"15)1617func wellFormed() capture {18 return capture{19 Source: SourceName,20 Body: Body,21 Product: "Blue Marble: Next Generation, July 2004",22 Credit: "NASA Earth Observatory",23 AssetSHA256: strings.Repeat("d", 64),24 Width: 21600,25 Height: 10800,26 Map: mosaic{MaxZoom: 5, Extension: "jpg", LayerTitle: "Blue Marble"},27 Derive: derivation{Resampler: "catmull-rom-fixed15", JPEGQuality: 90},28 Features: features{29 Edition: "stated",30 BordersSHA256: "bb", PlacesSHA256: "pp",31 Countries: []country{32 {Name: "Vale", A3: "VAL", Continent: "Europe", LabelLon: 10, LabelLat: 50,33 Polygons: [][][][2]float64{{{{5, 45}, {15, 45}, {15, 55}, {5, 55}, {5, 45}}}}},34 {Name: "Mar", A3: "MAR2", Continent: "Oceania", LabelLon: 150, LabelLat: -20,35 Polygons: [][][][2]float64{{{{145, -25}, {155, -25}, {155, -15}, {145, -15}, {145, -25}}}}},36 },37 Capitals: []capital{38 {Name: "Vale City", Country: "Vale", A3: "VAL", Lat: 50, Lon: 10},39 // A microstate no border draws at this scale: its continent is40 // the nearest drawn ground's.41 {Name: "Atoll", Country: "Atoll", A3: "ATL", Lat: -18, Lon: 152},42 },43 },44 }45}4647// writeArchive lays out the smallest archive holding one volume of one world,48// carrying the given capture.49func writeArchive(t *testing.T, kind string, raw capture) *archive.Archive {50 t.Helper()51 root := t.TempDir()52 body, err := json.Marshal(raw)53 if err != nil {54 t.Fatal(err)55 }56 worldDir := filepath.Join(root, "games", "g-1", "maps", "world-1")57 mustWrite(t, filepath.Join(root, "archive.json"),58 `{"games":[{"directory":"games/g-1","id":1,"title":"Earth","source":"nasa-blue-marble"}]}`)59 mustWrite(t, filepath.Join(root, "games", "g-1", "game.json"),60 `{"id":1,"title":"Earth","maps":[{"directory":"games/g-1/maps/world-1","id":1,"slug":"earth","title":"Earth"}]}`)61 mustWrite(t, filepath.Join(worldDir, "snapshots", "index.json"),62 `[{"capturedAt":"2026-08-03T14:30:39Z","contentHash":"h","kind":"`+kind+`","sourceId":1,"sourceUrl":"/x"}]`)63 mustWrite(t, filepath.Join(worldDir, "snapshots", "map", "h.json"), string(body))64 store, err := archive.Open(root)65 if err != nil {66 t.Fatal(err)67 }68 return store69}7071func mustWrite(t *testing.T, path, body string) {72 t.Helper()73 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {74 t.Fatal(err)75 }76 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {77 t.Fatal(err)78 }79}8081// TestTranslateReadsACaptureWhole walks one hand-written capture into the82// interchange document: a raster-only Earth -- one world, one lens, the shared83// whole-sphere declarations, and nothing else.84func TestTranslateReadsACaptureWhole(t *testing.T) {85 store := writeArchive(t, CaptureKind, wellFormed())86 document, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))87 if err != nil {88 t.Fatal(err)89 }90 if document.Volume.Slug != "earth" || document.Volume.Title != "Earth" {91 t.Errorf("volume %s/%s; the planet is the volume", document.Volume.Slug, document.Volume.Title)92 }93 if document.Source.Name != SourceName || document.Source.Label != "NASA Earth Observatory" {94 t.Errorf("provenance %s/%s; the ledger and the credit both read from here",95 document.Source.Name, document.Source.Label)96 }97 if document.Source.IDSpace != doc.IDSpaceDerived {98 t.Errorf("id space %q; a base map numbers nothing", document.Source.IDSpace)99 }100 if len(document.Icons) != 0 {101 t.Errorf("the document ships %d pieces of artwork; a base map has none", len(document.Icons))102 }103 if len(document.Worlds) != 1 {104 t.Fatalf("%d worlds, want the one Earth", len(document.Worlds))105 }106 world := document.Worlds[0]107 if world.Slug != "earth" || world.Title != "Earth" {108 t.Errorf("world %s/%s", world.Slug, world.Title)109 }110 if world.Center != doc.EquirectCenter() {111 t.Errorf("the world opens at %v, not the picture's centre", world.Center)112 }113 if world.Capture.Kind != CaptureKind || world.Capture.CapturedAt != "2026-08-03T14:30:39Z" {114 t.Errorf("capture provenance did not travel: %+v", world.Capture)115 }116117 want := map[string]string{118 semconv.KeyGeometrySurface: semconv.SurfaceSphere,119 semconv.KeyGeometryProjection: semconv.ProjectionEquirect,120 semconv.KeyGeometryEquirectPx: doc.EquirectPx,121 semconv.KeyGeometryEquirectDeg: doc.EquirectDeg,122 semconv.KeyGeometryBody: "earth",123 semconv.KeyGeometryRadiusKM: "6371.0088",124 }125 if len(world.Attrs) != len(want) {126 t.Errorf("the world carries %d attributes %v, want %d", len(world.Attrs), world.Attrs, len(want))127 }128 for key, value := range want {129 if world.Attrs[key] != value {130 t.Errorf("world says %s=%q, want %q", key, world.Attrs[key], value)131 }132 }133134 if len(world.Lenses) != 1 {135 t.Fatalf("%d lenses, want the one base map", len(world.Lenses))136 }137 lens := world.Lenses[0]138 if lens.Name != "Blue Marble" || lens.TileSet != TileSet {139 t.Errorf("lens %s/%s", lens.Name, lens.TileSet)140 }141 if lens.Frame == nil || lens.Frame.MaxZoom != 5 || lens.Frame.Format != "jpg" {142 t.Fatalf("frame %+v", lens.Frame)143 }144 for zoom := 0; zoom <= 5; zoom++ {145 maxX, maxY := doc.EquirectLevelExtent(zoom)146 window, held := lens.Frame.Windows[strconv.Itoa(zoom)]147 if !held || window.MaxX != maxX || window.MaxY != maxY {148 t.Errorf("zoom %d window %+v, want the whole-sphere extent %d,%d", zoom, window, maxX, maxY)149 }150 }151152 if len(world.Collections) != 3 {153 t.Fatalf("%d collections, want two continents of capitals and the countries", len(world.Collections))154 }155 europe, oceania, countries := world.Collections[0], world.Collections[1], world.Collections[2]156 if europe.Title != "Europe" || oceania.Title != "Oceania" {157 t.Errorf("capitals read %q then %q; continents sort", europe.Title, oceania.Title)158 }159 for _, held := range []doc.Collection{europe, oceania} {160 if held.Group != "Capitals" || held.Kind != doc.KindPoint ||161 held.Attrs[semconv.KeyRenderAs] != semconv.RenderAsPin ||162 held.Attrs[semconv.KeyIconStd] != capitalIcon {163 t.Errorf("capitals collection %q is arranged as %+v", held.Title, held.Attrs)164 }165 }166 if countries.Title != "Countries" || countries.Kind != doc.KindArea ||167 countries.Attrs[semconv.KeyLabelPolicy] != semconv.LabelQuiet {168 t.Errorf("the ground is arranged as %q %s %+v", countries.Title, countries.Kind, countries.Attrs)169 }170 if len(countries.Features) != 2 {171 t.Fatalf("%d countries", len(countries.Features))172 }173174 vale := countries.Features[0]175 capitalOfVale := europe.Features[0]176 if capitalOfVale.Title != "Vale City" || capitalOfVale.Subtitle != "Vale" {177 t.Errorf("the capital reads %q / %q", capitalOfVale.Title, capitalOfVale.Subtitle)178 }179 if capitalOfVale.Member != vale.ID {180 t.Errorf("the capital stands on member %d, its country is %d", capitalOfVale.Member, vale.ID)181 }182 if capitalOfVale.Attrs[semconv.KeyGeoLat] != "50" || capitalOfVale.Attrs[semconv.KeyGeoLon] != "10" {183 t.Errorf("the published coordinates did not travel verbatim: %v", capitalOfVale.Attrs)184 }185 if at := worldPoint(10, 50); capitalOfVale.At == nil || *capitalOfVale.At != at {186 t.Errorf("the capital stands at %v, want %v", capitalOfVale.At, at)187 }188189 atoll := oceania.Features[0]190 if atoll.Title != "Atoll" || atoll.Member != 0 {191 t.Errorf("a microstate capital should stand on open map: %+v", atoll)192 }193194 if len(vale.Geometry) != 1 || vale.Geometry[0].Type != "MultiPolygon" {195 t.Fatalf("a country draws %+v", vale.Geometry)196 }197 var rings [][][][2]float64198 if err := json.Unmarshal(vale.Geometry[0].Coordinates, &rings); err != nil {199 t.Fatal(err)200 }201 corner := worldPoint(5, 45)202 if rings[0][0][0] != [2]float64{rounded(corner.Lng), rounded(corner.Lat)} {203 t.Errorf("the ground's first vertex is %v, want the converted corner %v", rings[0][0][0], corner)204 }205 if vale.Center == nil || *vale.Center != worldPoint(10, 50) {206 t.Errorf("the country frames at %v, want its label point", vale.Center)207 }208209 // The same archived bytes give the same document: identities included.210 again, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))211 if err != nil {212 t.Fatal(err)213 }214 if again.Worlds[0].ID != world.ID {215 t.Error("two translations of one capture minted different identities")216 }217}218219// TestTranslateRefusesWhatItCannotStandFor states the reader's preconditions:220// another source's bytes, another body, a capture with no pyramid, no digest or221// no size are refusals, not guesses.222func TestTranslateRefusesWhatItCannotStandFor(t *testing.T) {223 tests := []struct {224 name string225 kind string226 spoil func(*capture)227 says string228 }{229 {"another source's kind", "trek-map", func(*capture) {}, "answers only for"},230 {"another source's name", CaptureKind, func(c *capture) { c.Source = "nasa-trek" }, "says its source is"},231 {"another body", CaptureKind, func(c *capture) { c.Body = "mars" }, "answers only for"},232 {"no pyramid", CaptureKind, func(c *capture) { c.Map.MaxZoom = 0 }, "declares no pyramid"},233 {"no features", CaptureKind, func(c *capture) { c.Features = features{} }, "carries no features"},234 {"a country with no identity", CaptureKind, func(c *capture) {235 c.Features.Countries[0].Continent = ""236 }, "carries no identity"},237 {"a capital off the planet", CaptureKind, func(c *capture) {238 c.Features.Capitals[0].Lat = 91239 }, "sits at"},240 {"no digest", CaptureKind, func(c *capture) { c.AssetSHA256 = "" }, "names no source digest"},241 {"no size", CaptureKind, func(c *capture) { c.Width = 0 }, "names no source image size"},242 }243 for _, tt := range tests {244 t.Run(tt.name, func(t *testing.T) {245 raw := wellFormed()246 tt.spoil(&raw)247 store := writeArchive(t, tt.kind, raw)248 _, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))249 if err == nil || !strings.Contains(err.Error(), tt.says) {250 t.Fatalf("the capture was read anyway: %v", err)251 }252 })253 }254}