clone

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

archive/golden-reference
atlas / internal / trekmap / trekmap_test.go
trekmap_test.go 11.5K
1package trekmap23import (4 "bytes"5 "encoding/json"6 "math"7 "strconv"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/atlas/internal/icons"12 "github.com/FelineStateMachine/atlas/internal/mgdoc"13 "github.com/FelineStateMachine/atlas/internal/semconv"14)1516// fixture is a small capture exercising everything Translate decides: two17// feature types, real Gazetteer coordinates east and west of the meridian,18// both hemispheres, and a feature with no origin text.19func fixture() Capture {20 return Capture{21 Source: Source,22 Body: "mars",23 Layer: "Mars_Viking_MDIM21_ClrMosaic_global_232m",24 MapSlug: "global",25 MapTitle: "Global",26 Map: MapConfig{27 MaxZoom: 6,28 Extension: "jpg",29 LayerTitle: "Viking MDIM 2.1",30 },31 Variants: []Variant{32 {Layer: "Mars_MGS_MOLA_ClrShade_merge_global_463m",33 Title: "MOLA Elevation", MaxZoom: 6, Extension: "jpg"},34 },35 Features: []Feature{36 {ID: 4453, Name: "Olympus Mons", Type: "Mons, montes", Code: "MO",37 Latitude: 18.6528, Longitude: 226.1975, DiameterKM: 610.13,38 Origin: "Classical albedo feature name."},39 {ID: 2071, Name: "Gale", Type: "Crater, craters", Code: "AA",40 Latitude: -5.3672, Longitude: 137.811, DiameterKM: 154.084,41 Origin: "Walter F.; Australian astronomer (1865-1945)."},42 {ID: 2432, Name: "Hellas Planitia", Type: "Planitia, planitiae", Code: "PL",43 Latitude: -42.4301, Longitude: 70.5025, DiameterKM: 2299.16},44 },45 }46}4748func translate(t *testing.T, capture Capture) mgdoc.Map {49 t.Helper()50 doc, err := json.Marshal(capture)51 if err != nil {52 t.Fatal(err)53 }54 out, err := Translate(doc)55 if err != nil {56 t.Fatal(err)57 }58 var m mgdoc.Map59 if err := json.Unmarshal(out, &m); err != nil {60 t.Fatal(err)61 }62 return m63}6465func TestTranslateIsDeterministic(t *testing.T) {66 doc, _ := json.Marshal(fixture())67 first, err := Translate(doc)68 if err != nil {69 t.Fatal(err)70 }7172 // The same features arriving in a different order are the same capture.73 shuffled := fixture()74 shuffled.Features[0], shuffled.Features[2] = shuffled.Features[2], shuffled.Features[0]75 reordered, _ := json.Marshal(shuffled)76 second, err := Translate(reordered)77 if err != nil {78 t.Fatal(err)79 }80 if !bytes.Equal(first, second) {81 t.Fatal("the same capture translated to different bytes")82 }83}8485func TestTranslateShape(t *testing.T) {86 m := translate(t, fixture())8788 if m.Slug != "global" || m.Title != "Global" {89 t.Fatalf("map identity: %q %q", m.Slug, m.Title)90 }91 if m.Game.Slug != "mars" || m.Game.Title != "Mars" {92 t.Fatalf("game identity: %q %q", m.Game.Slug, m.Game.Title)93 }9495 if len(m.Config.TileSets) != 2 {96 t.Fatalf("tile sets: %d", len(m.Config.TileSets))97 }98 set := m.Config.TileSets[0]99 if set.Path != "mars/EQ/Mars_Viking_MDIM21_ClrMosaic_global_232m" {100 t.Fatalf("tile set path: %q", set.Path)101 }102 if set.Name != "Viking MDIM 2.1" || set.Extension != "jpg" {103 t.Fatalf("tile set: %+v", set)104 }105 if set.MinZoom != 0 || set.MaxZoom != 6 {106 t.Fatalf("zoom range: %d..%d", set.MinZoom, set.MaxZoom)107 }108 // The sibling mosaic rides the same map as a second layer in the same109 // window, under its own name and path.110 sibling := m.Config.TileSets[1]111 if sibling.Name != "MOLA Elevation" ||112 sibling.Path != "mars/EQ/Mars_MGS_MOLA_ClrShade_merge_global_463m" {113 t.Fatalf("variant tile set: %+v", sibling)114 }115 // The planet fills the width of the world square and the top half of its116 // height, at every level of both pyramids.117 for _, layer := range m.Config.TileSets {118 for zoom := 0; zoom <= layer.MaxZoom; zoom++ {119 bounds, ok := layer.Bounds[strconv.Itoa(zoom)]120 if !ok {121 t.Fatalf("%s declares no bounds for zoom %d", layer.Name, zoom)122 }123 wantX, wantY := 1<<zoom-1, 1<<zoom/2-1124 if zoom == 0 {125 wantX, wantY = 0, 0126 }127 if bounds.X.Min != 0 || bounds.Y.Min != 0 || bounds.X.Max != wantX || bounds.Y.Max != wantY {128 t.Fatalf("%s zoom %d bounds: %+v", layer.Name, zoom, bounds)129 }130 }131 }132133 if len(m.Groups) != 1 || m.Groups[0].Title != "Nomenclature" {134 t.Fatalf("groups: %+v", m.Groups)135 }136 titles := make([]string, 0, 3)137 for _, category := range m.Groups[0].Categories {138 titles = append(titles, category.Title+"/"+category.Icon)139 }140 want := "Crater/crater Mons/mons Planitia/planitia"141 if strings.Join(titles, " ") != want {142 t.Fatalf("categories: %v, want %s", titles, want)143 }144}145146// TestSyntheticCoordinatesRoundTrip runs the synthetic latitude and longitude147// through the viewer's projection and expects each feature's equirectangular148// pixel back: longitude across the full 8192-pixel width, latitude down the149// 4096-pixel top half, with the Gazetteer's 0..360 longitudes wrapped into150// the mosaic's -180..180 window.151func TestSyntheticCoordinatesRoundTrip(t *testing.T) {152 m := translate(t, fixture())153154 pixel := func(longitude, latitude float64) (float64, float64) {155 if longitude >= 180 {156 longitude -= 360157 }158 return (longitude + 180) / 360 * mgdoc.WorldSize,159 (90 - latitude) / 180 * mgdoc.WorldSize / 2160 }161 byTitle := map[string][2]float64{}162 for _, feature := range fixture().Features {163 x, y := pixel(feature.Longitude, feature.Latitude)164 byTitle[feature.Name] = [2]float64{x, y}165 }166167 seen := 0168 for _, group := range m.Groups {169 for _, category := range group.Categories {170 for _, location := range category.Locations {171 want, ok := byTitle[location.Title]172 if !ok {173 continue174 }175 seen++176 x := mgdoc.ProjectX(location.Longitude, mgdoc.SourceZoom, 0)177 y := mgdoc.ProjectY(location.Latitude, mgdoc.SourceZoom, 0)178 if math.Abs(x-want[0]) > 1e-6 || math.Abs(y-want[1]) > 1e-6 {179 t.Fatalf("%s projects to %.6f,%.6f, want %.6f,%.6f",180 location.Title, x, y, want[0], want[1])181 }182 }183 }184 }185 if seen != len(byTitle) {186 t.Fatalf("saw %d of %d features", seen, len(byTitle))187 }188}189190func TestDescriptionsCarryTheRealPlace(t *testing.T) {191 m := translate(t, fixture())192 descriptions := map[string]string{}193 for _, group := range m.Groups {194 for _, category := range group.Categories {195 for _, location := range category.Locations {196 descriptions[location.Title] = location.Description197 }198 }199 }200 olympus := descriptions["Olympus Mons"]201 if olympus != "18.65°N 226.2°E · 610.13 km across — Classical albedo feature name." {202 t.Fatalf("Olympus Mons card: %q", olympus)203 }204 // A feature without origin text still says where and how big it is.205 hellas := descriptions["Hellas Planitia"]206 if hellas != "42.43°S 70.5°E · 2299.16 km across" {207 t.Fatalf("Hellas card: %q", hellas)208 }209 for title, description := range descriptions {210 if strings.Contains(description, "http") {211 t.Fatalf("%s card carries a link: %q", title, description)212 }213 }214}215216func TestIdentifiersAreStable(t *testing.T) {217 first := translate(t, fixture())218 second := translate(t, fixture())219 if first.ID != second.ID || first.ID <= 0 || first.ID > math.MaxInt32 {220 t.Fatalf("map id: %d then %d", first.ID, second.ID)221 }222 firstLocations := locationIDs(first)223 secondLocations := locationIDs(second)224 for title, id := range firstLocations {225 if secondLocations[title] != id {226 t.Fatalf("%s renumbered: %d then %d", title, id, secondLocations[title])227 }228 if id <= 0 || id > math.MaxInt32 {229 t.Fatalf("%s id %d leaves the positive int31 range", title, id)230 }231 }232}233234func locationIDs(m mgdoc.Map) map[string]int64 {235 out := make(map[string]int64)236 for _, group := range m.Groups {237 for _, category := range group.Categories {238 for _, location := range category.Locations {239 out[location.Title] = location.ID240 }241 }242 }243 return out244}245246func TestTranslateRejectsBadCaptures(t *testing.T) {247 cases := []struct {248 name string249 mutate func(*Capture)250 mention string251 }{252 {"foreign source", func(c *Capture) { c.Source = "someone-else" }, "source"},253 {"no layer", func(c *Capture) { c.Layer = "" }, "no map"},254 {"no pyramid", func(c *Capture) { c.Map.MaxZoom = 0 }, "pyramid"},255 {"no features", func(c *Capture) { c.Features = nil }, "features"},256 {"unnamed feature", func(c *Capture) { c.Features[0].Name = "" }, "name"},257 {"feature without id", func(c *Capture) { c.Features[0].ID = 0 }, "identifier"},258 {"latitude off the planet", func(c *Capture) { c.Features[0].Latitude = 91 }, "latitude"},259 {"longitude off the planet", func(c *Capture) { c.Features[0].Longitude = -5 }, "longitude"},260 {"doubled feature", func(c *Capture) { c.Features[1].ID = c.Features[0].ID }, "collision"},261 {"doubled layer", func(c *Capture) { c.Variants[0].Layer = c.Layer }, "twice"},262 {"depthless variant", func(c *Capture) { c.Variants[0].MaxZoom = 0 }, "pyramid"},263 }264 for _, test := range cases {265 capture := fixture()266 test.mutate(&capture)267 doc, err := json.Marshal(capture)268 if err != nil {269 t.Fatal(err)270 }271 if _, err := Translate(doc); err == nil || !strings.Contains(err.Error(), test.mention) {272 t.Fatalf("%s: %v", test.name, err)273 }274 }275}276277// TestDeclaredMappingInverts is the honesty check the geometry conventions278// stand on: a pin's packed synthetic position, run through the viewer's279// projection and backward through the mapping the map itself declares, must280// recover the coordinates the Gazetteer published -- for every pin, without281// the pin saying anything.282func TestDeclaredMappingInverts(t *testing.T) {283 m := translate(t, fixture())284 mapping, err := semconv.ParseEquirect(285 m.Attrs[semconv.KeyGeometryEquirectPx],286 m.Attrs[semconv.KeyGeometryEquirectDeg])287 if err != nil {288 t.Fatal(err)289 }290 if m.Attrs[semconv.KeyGeometrySurface] != semconv.SurfaceSphere ||291 m.Attrs[semconv.KeyGeometryBody] != "mars" {292 t.Fatalf("geometry attrs: %v", m.Attrs)293 }294 published := map[string][2]float64{}295 for _, feature := range fixture().Features {296 published[feature.Name] = [2]float64{feature.Latitude, feature.Longitude}297 }298 for _, group := range m.Groups {299 for _, category := range group.Categories {300 for _, location := range category.Locations {301 x := mgdoc.ProjectX(location.Longitude, mgdoc.SourceZoom, 0)302 y := mgdoc.ProjectY(location.Latitude, mgdoc.SourceZoom, 0)303 lat, lon := mapping.Invert(x, y)304 if lon < 0 {305 lon += 360306 }307 want := published[location.Title]308 if math.Abs(lat-want[0]) > 1e-9 || math.Abs(lon-want[1]) > 1e-9 {309 t.Errorf("%s recovered at %v,%v, published %v", location.Title, lat, lon, want)310 }311 // And the pin's own account agrees with what was published.312 if location.Attrs[semconv.KeyGeoLat] == "" || location.Attrs[semconv.KeyGeoLon] == "" {313 t.Errorf("%s carries no true coordinates", location.Title)314 }315 }316 }317 }318}319320// TestStandardIconsAreVendored holds the curation table to the library: a321// promise the translator writes into a capture's translation must be one the322// resolution pass can keep.323func TestStandardIconsAreVendored(t *testing.T) {324 for code, ref := range standardIcons {325 if _, _, err := icons.Standard(ref); err != nil {326 t.Errorf("code %s names %s: %v", code, ref, err)327 }328 }329}330331func TestCategoriesDeclareTheirStandardIcons(t *testing.T) {332 m := translate(t, fixture())333 declared := map[string]string{}334 for _, group := range m.Groups {335 for _, category := range group.Categories {336 declared[category.Title] = category.Attrs["atlas.icon.std"]337 }338 }339 want := map[string]string{340 "Crater": "maki/circle-stroked",341 "Mons": "maki/mountain",342 "Planitia": "maki/square-stroked",343 }344 for title, ref := range want {345 if declared[title] != ref {346 t.Errorf("%s declares %q, want %q", title, declared[title], ref)347 }348 }349}350351func TestMaybeTranslatePassesOthersThrough(t *testing.T) {352 doc := []byte(`{"id": 5, "title": "As Captured"}`)353 out, err := MaybeTranslate("map", doc)354 if err != nil {355 t.Fatal(err)356 }357 if !bytes.Equal(out, doc) {358 t.Fatal("a MapGenie snapshot was rewritten")359 }360}