1package nasatrek23import (4 "encoding/json"5 "log/slog"6 "math"7 "os"8 "path/filepath"9 "strconv"10 "strings"11 "testing"1213 "github.com/FelineStateMachine/atlas/format/semconv"14 "github.com/FelineStateMachine/atlas/internal/generate/archive"15 "github.com/FelineStateMachine/atlas/internal/generate/doc"16)1718// The Mars corpus document: the one real capture this repository keeps, because19// everything in it is public domain. It is the reference tree's reading of the20// same NASA Trek capture this package reads, and the tests below hold this21// package's coordinate design against it fact by fact -- every feature's pixel,22// every level's window, every canonical tile-set path -- with no archive, no23// network and no environment.24const (25 corpusDoc = "../../../../testdata/corpus/translators/nasa-trek.doc.json"26 corpusSummary = "../../../../testdata/corpus/translators/nasa-trek.fixture.json"27)2829// marsDocument is as much of the corpus document as these tests read. The field30// names are the reference tree's, which were MapGenie's; nothing outside this31// file names them.32type marsDocument struct {33 ID int64 `json:"id"`34 Slug string `json:"slug"`35 Title string `json:"title"`36 Lat float64 `json:"initial_latitude"`37 Lng float64 `json:"initial_longitude"`38 Attrs map[string]string `json:"atlas_attrs"`39 Game struct{ Slug, Title string }40 Config struct {41 TileSets []struct {42 Name string `json:"name"`43 Path string `json:"path"`44 MinZoom int `json:"min_zoom"`45 MaxZoom int `json:"max_zoom"`46 Extension string `json:"extension"`47 Bounds map[string]struct {48 X struct{ Min, Max int }49 Y struct{ Min, Max int }50 } `json:"bounds"`51 } `json:"tile_sets"`52 } `json:"config"`53 Groups []struct {54 Title string `json:"title"`55 Categories []struct {56 ID int64 `json:"id"`57 Title string `json:"title"`58 Icon string `json:"icon"`59 Visible bool `json:"visible"`60 Attrs map[string]string `json:"atlas_attrs"`61 Locations []struct {62 ID int64 `json:"id"`63 Title string `json:"title"`64 Description string `json:"description"`65 Latitude float64 `json:"latitude"`66 Longitude float64 `json:"longitude"`67 Attrs map[string]string `json:"atlas_attrs"`68 } `json:"locations"`69 } `json:"categories"`70 } `json:"groups"`71}7273func readMars(t *testing.T) marsDocument {74 t.Helper()75 var out marsDocument76 readJSON(t, corpusDoc, &out)77 return out78}7980// readSummary reads the fixture's own account of its size, so the counts these81// tests hold the document to are the capture's rather than constants copied in82// beside it.83func readSummary(t *testing.T) (categories, locations, tileSets int) {84 t.Helper()85 var summary struct {86 Document struct {87 TileSets int `json:"tileSets"`88 Categories int `json:"categories"`89 Locations int `json:"locations"`90 } `json:"document"`91 }92 readJSON(t, corpusSummary, &summary)93 return summary.Document.Categories, summary.Document.Locations, summary.Document.TileSets94}9596func readJSON(t *testing.T, path string, dst any) {97 t.Helper()98 data, err := os.ReadFile(path)99 if err != nil {100 t.Fatalf("read %s: %v", path, err)101 }102 if err := json.Unmarshal(data, dst); err != nil {103 t.Fatalf("decode %s: %v", path, err)104 }105}106107// near compares a coordinate the way a fixture round-tripped through JSON has108// to be compared: the reference document was written out and read back, so its109// last bit is a decimal spelling rather than the float that produced it.110func near(got, want float64) bool {111 return math.Abs(got-want) <= 1e-9*math.Max(1, math.Abs(want))112}113114// TestEveryMarsFeatureLandsOnItsPixel is the coordinate design, held against115// all two thousand real places at once: the Gazetteer coordinates each location116// carries verbatim, pushed through this package's projection, land on exactly117// the position the reference tree published for it.118func TestEveryMarsFeatureLandsOnItsPixel(t *testing.T) {119 mars := readMars(t)120 _, locations, _ := readSummary(t)121 checked := 0122 for _, group := range mars.Groups {123 for _, category := range group.Categories {124 for _, location := range category.Locations {125 lat, err := strconv.ParseFloat(location.Attrs[semconv.KeyGeoLat], 64)126 if err != nil {127 t.Fatalf("%s carries no readable %s: %v", location.Title, semconv.KeyGeoLat, err)128 }129 lon, err := strconv.ParseFloat(location.Attrs[semconv.KeyGeoLon], 64)130 if err != nil {131 t.Fatalf("%s carries no readable %s: %v", location.Title, semconv.KeyGeoLon, err)132 }133 x, y := worldPixel(lon, lat)134 at := doc.SyntheticPosition(x, y)135 if !near(at.Lat, location.Latitude) || !near(at.Lng, location.Longitude) {136 t.Fatalf("%s at %v°,%v°E lands on %v,%v; the fixture put it at %v,%v",137 location.Title, lat, lon, at.Lat, at.Lng, location.Latitude, location.Longitude)138 }139 // And the card opens with the place the projection was fed, so a140 // reader is never told one thing and shown another.141 if !strings.HasPrefix(location.Description, place(lat, lon)) {142 t.Fatalf("%s describes itself as %q, which does not open with %q",143 location.Title, location.Description, place(lat, lon))144 }145 checked++146 }147 }148 }149 if checked != locations {150 t.Errorf("checked %d locations, the fixture summary says %d", checked, locations)151 }152}153154// TestMarsMosaicsDeclareTheHalfHeightPyramid is the Trek zoom quirk: a mosaic is155// two tiles wide and one tall at its own zoom zero, so the square level z holds156// 2^z columns and 2^(z-1) rows. The fixture's per-level bounds for both real157// mosaics are exactly what LevelExtent computes, and what frameOf therefore158// declares to the deriver.159func TestMarsMosaicsDeclareTheHalfHeightPyramid(t *testing.T) {160 mars := readMars(t)161 _, _, tileSets := readSummary(t)162 if len(mars.Config.TileSets) != tileSets {163 t.Fatalf("%d tile sets, the fixture summary says %d", len(mars.Config.TileSets), tileSets)164 }165 for _, set := range mars.Config.TileSets {166 frame := frameOf(set.MaxZoom, set.Extension)167 if frame.MaxZoom != set.MaxZoom || frame.Format != set.Extension {168 t.Errorf("%s frames %d/%s, fixture %d/%s",169 set.Name, frame.MaxZoom, frame.Format, set.MaxZoom, set.Extension)170 }171 if len(frame.Windows) != len(set.Bounds) {172 t.Errorf("%s declares %d windows, fixture %d", set.Name, len(frame.Windows), len(set.Bounds))173 }174 for zoom := 0; zoom <= set.MaxZoom; zoom++ {175 bound, held := set.Bounds[strconv.Itoa(zoom)]176 if !held {177 t.Fatalf("%s records no bounds for zoom %d", set.Name, zoom)178 }179 maxX, maxY := LevelExtent(zoom)180 if bound.X.Min != 0 || bound.Y.Min != 0 || bound.X.Max != maxX || bound.Y.Max != maxY {181 t.Errorf("%s zoom %d spans x %d..%d y %d..%d, LevelExtent says 0..%d and 0..%d",182 set.Name, zoom, bound.X.Min, bound.X.Max, bound.Y.Min, bound.Y.Max, maxX, maxY)183 }184 window := frame.Windows[strconv.Itoa(zoom)]185 if window.MaxX != maxX || window.MaxY != maxY || window.MinX != 0 || window.MinY != 0 {186 t.Errorf("%s zoom %d window %+v disagrees with its own extent", set.Name, zoom, window)187 }188 }189 }190}191192// TestMarsTileSetPathsAreCanonical holds the one spelling the crawler and the193// reader must share: a body's mosaic is captured under <body>/EQ/<layer>, and194// both real mosaics answer to it.195func TestMarsTileSetPathsAreCanonical(t *testing.T) {196 mars := readMars(t)197 for _, set := range mars.Config.TileSets {198 layer, found := strings.CutPrefix(set.Path, mars.Game.Slug+"/EQ/")199 if !found || layer == "" || strings.Contains(layer, "/") {200 t.Errorf("%s sits at %q, which is not <body>/EQ/<layer>", set.Name, set.Path)201 continue202 }203 if TileSetPath(mars.Game.Slug, layer) != set.Path {204 t.Errorf("TileSetPath spells %q, the capture was taken under %q",205 TileSetPath(mars.Game.Slug, layer), set.Path)206 }207 }208}209210// TestMarsWorldSpeaksTheDeclaredFlattening: the world attributes are the whole211// coordinate design -- a sphere flattened into the top half of the square by212// the equirectangular projection -- and the fixture carries exactly the values213// this package's constants spell.214func TestMarsWorldSpeaksTheDeclaredFlattening(t *testing.T) {215 mars := readMars(t)216 want := map[string]string{217 semconv.KeyGeometrySurface: semconv.SurfaceSphere,218 semconv.KeyGeometryProjection: semconv.ProjectionEquirect,219 semconv.KeyGeometryEquirectPx: doc.EquirectPx,220 semconv.KeyGeometryEquirectDeg: doc.EquirectDeg,221 semconv.KeyGeometryBody: mars.Game.Slug,222 }223 if len(mars.Attrs) != len(want) {224 t.Errorf("the fixture carries %d world attributes %v, this reader writes %d",225 len(mars.Attrs), mars.Attrs, len(want))226 }227 for key, value := range want {228 if mars.Attrs[key] != value {229 t.Errorf("world says %s=%q, fixture %q", key, value, mars.Attrs[key])230 }231 }232 // A reader opens on the middle of the picture: halfway across the square, a233 // quarter down it.234 center := doc.SyntheticPosition(doc.SyntheticWorldSize/2, doc.SyntheticWorldSize/4)235 if !near(mars.Lat, center.Lat) || mars.Lng != center.Lng {236 t.Errorf("the fixture opens at %v,%v, the picture's centre is %v,%v",237 mars.Lat, mars.Lng, center.Lat, center.Lng)238 }239}240241// TestMarsIdentitiesDeriveFromStableNames: the Gazetteer numbers nothing a242// bundle can use, so every identity is minted from a stable name -- and the243// fixture's real numbers are exactly the ones those names mint, world and244// collections alike.245func TestMarsIdentitiesDeriveFromStableNames(t *testing.T) {246 mars := readMars(t)247 categories, _, _ := readSummary(t)248 scope := mars.Game.Slug + "/" + mars.Slug249250 ids := doc.NewIDSpace()251 worldID, err := ids.Claim("trek:map:" + scope)252 if err != nil {253 t.Fatal(err)254 }255 if worldID != mars.ID {256 t.Errorf("the world's name mints %d, the fixture recorded %d", worldID, mars.ID)257 }258259 counted := 0260 var previous string261 for _, group := range mars.Groups {262 for _, category := range group.Categories {263 counted++264 id, err := ids.Claim("trek:type:" + scope + ":" + category.Title)265 if err != nil {266 t.Fatal(err)267 }268 if id != category.ID {269 t.Errorf("%s mints %d, the fixture recorded %d", category.Title, id, category.ID)270 }271 // One legend rule and one artwork rule, held across the whole real272 // nomenclature: types read in sorted order, and a collection's273 // artwork key is its own title made a slug.274 if previous != "" && category.Title < previous {275 t.Errorf("%s reads before %s; feature types sort", previous, category.Title)276 }277 previous = category.Title278 if category.Icon != doc.Slugify(category.Title) {279 t.Errorf("%s lends artwork key %q, want %q",280 category.Title, category.Icon, doc.Slugify(category.Title))281 }282 if category.Attrs[semconv.KeyRenderAs] != semconv.RenderAsPin {283 t.Errorf("%s renders as %q; a gazetteer publishes markers", category.Title,284 category.Attrs[semconv.KeyRenderAs])285 }286 // Every real feature type earned a standard glyph, and the glyph is287 // one this reader's table can actually name.288 std := category.Attrs[semconv.KeyIconStd]289 if std == "" {290 t.Errorf("%s names no standard glyph and a gazetteer has no artwork of its own",291 category.Title)292 continue293 }294 known := false295 for _, candidate := range standardIcons {296 if candidate == std {297 known = true298 break299 }300 }301 if !known {302 t.Errorf("%s wears %q, which this reader's table never names", category.Title, std)303 }304 }305 }306 if counted != categories {307 t.Errorf("%d categories, the fixture summary says %d", counted, categories)308 }309}310311// --- the reader itself, over the smallest capture that exercises it ----------312313// wellFormed is a capture that is everything a good one is, so a test can spoil314// exactly one thing about it and see the reader refuse for exactly that reason.315func wellFormed() capture {316 return capture{317 Source: source,318 Body: "mars",319 Layer: "viking",320 MapSlug: "global",321 MapTitle: "Global",322 Map: mosaic{MaxZoom: 2, Extension: "jpg", LayerTitle: "Viking"},323 Variants: []variant{{Layer: "mola", Title: "MOLA", MaxZoom: 1, Extension: "png"}},324 Features: []feature{325 {ID: 2, Name: "Olympus Mons", Type: "Mons, montes", Code: "MO",326 Latitude: 18.65, Longitude: 226.2, DiameterKM: 610.13,327 Origin: "The tallest mountain."},328 {ID: 1, Name: "Gale", Type: "Crater, craters", Code: "AA",329 Latitude: -5.37, Longitude: 137.81},330 },331 }332}333334// writeArchive lays out the smallest archive holding one volume of one world,335// carrying the given capture.336func writeArchive(t *testing.T, kind string, raw capture) *archive.Archive {337 t.Helper()338 root := t.TempDir()339 body, err := json.Marshal(raw)340 if err != nil {341 t.Fatal(err)342 }343 worldDir := filepath.Join(root, "games", "g-1", "maps", "world-1")344 mustWrite(t, filepath.Join(root, "archive.json"),345 `{"games":[{"directory":"games/g-1","id":1,"title":"Mars","source":"nasa-trek"}]}`)346 mustWrite(t, filepath.Join(root, "games", "g-1", "game.json"),347 `{"id":1,"title":"Mars","maps":[{"directory":"games/g-1/maps/world-1","id":1,"slug":"global","title":"Global"}]}`)348 mustWrite(t, filepath.Join(worldDir, "snapshots", "index.json"),349 `[{"capturedAt":"2026-01-01T00:00:00Z","contentHash":"h","kind":"`+kind+`","sourceId":1,"sourceUrl":"/x"}]`)350 mustWrite(t, filepath.Join(worldDir, "snapshots", "map", "h.json"), string(body))351 store, err := archive.Open(root)352 if err != nil {353 t.Fatal(err)354 }355 return store356}357358func mustWrite(t *testing.T, path, body string) {359 t.Helper()360 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {361 t.Fatal(err)362 }363 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {364 t.Fatal(err)365 }366}367368// TestTranslateReadsACaptureWhole walks one hand-written capture into the369// interchange document: the body becomes the volume, the map's mosaic leads the370// lens order and its siblings follow, and the flat feature list arranges itself371// into sorted typed collections.372func TestTranslateReadsACaptureWhole(t *testing.T) {373 store := writeArchive(t, captureKind, wellFormed())374 document, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))375 if err != nil {376 t.Fatal(err)377 }378 if document.Volume.Slug != "mars" || document.Volume.Title != "Mars" {379 t.Errorf("volume %s/%s; a planet is a volume named for the body", document.Volume.Slug, document.Volume.Title)380 }381 if document.Source.IDSpace != doc.IDSpaceDerived {382 t.Errorf("id space %q; the Gazetteer numbers nothing a bundle can use", document.Source.IDSpace)383 }384 if len(document.Icons) != 0 {385 t.Errorf("the document ships %d pieces of artwork; a gazetteer has none", len(document.Icons))386 }387 world := document.Worlds[0]388 if len(world.Lenses) != 2 {389 t.Fatalf("%d lenses, want the mosaic and its sibling", len(world.Lenses))390 }391 if world.Lenses[0].Name != "Viking" || world.Lenses[0].TileSet != TileSetPath("mars", "viking") {392 t.Errorf("the body's own picture leads, got %s/%s", world.Lenses[0].Name, world.Lenses[0].TileSet)393 }394 if world.Lenses[1].Name != "MOLA" || world.Lenses[1].TileSet != TileSetPath("mars", "mola") {395 t.Errorf("the sibling follows, got %s/%s", world.Lenses[1].Name, world.Lenses[1].TileSet)396 }397 if world.Lenses[1].Frame == nil || world.Lenses[1].Frame.MaxZoom != 1 {398 t.Errorf("a sibling's pyramid is its own: %+v", world.Lenses[1].Frame)399 }400401 if len(world.Collections) != 2 {402 t.Fatalf("%d collections, want the two feature types", len(world.Collections))403 }404 craters, mons := world.Collections[0], world.Collections[1]405 if craters.Title != "Crater" || mons.Title != "Mons" {406 t.Errorf("collections read %q then %q; types sort and keep the singular half",407 craters.Title, mons.Title)408 }409 if craters.Attrs[semconv.KeyIconStd] != "maki/circle-stroked" ||410 mons.Attrs[semconv.KeyIconStd] != "maki/mountain" {411 t.Errorf("glyphs %q and %q; the IAU code names the artwork",412 craters.Attrs[semconv.KeyIconStd], mons.Attrs[semconv.KeyIconStd])413 }414 olympus := mons.Features[0]415 if olympus.Attrs[semconv.KeyGeoLat] != "18.65" || olympus.Attrs[semconv.KeyGeoLon] != "226.2" {416 t.Errorf("the Gazetteer's own coordinates travel verbatim, got %v", olympus.Attrs)417 }418 if olympus.Description != "18.65°N 226.2°E · 610.13 km across — The tallest mountain." {419 t.Errorf("the card reads %q", olympus.Description)420 }421422 // The same archived bytes give the same document: identities included.423 again, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))424 if err != nil {425 t.Fatal(err)426 }427 if again.Worlds[0].ID != world.ID ||428 again.Worlds[0].Collections[0].Features[0].ID != craters.Features[0].ID {429 t.Error("two translations of one capture minted different identities")430 }431}432433// TestTranslateRefusesWhatItCannotStandFor states the reader's preconditions:434// another source's bytes, a mosaic captured twice, and a feature off the planet435// are refusals, not guesses.436func TestTranslateRefusesWhatItCannotStandFor(t *testing.T) {437 tests := []struct {438 name string439 kind string440 spoil func(*capture)441 says string442 }{443 {"another source's bytes", "mapgenie-map", func(*capture) {}, "answers only for"},444 {"a mosaic captured twice", captureKind, func(c *capture) {445 c.Variants = append(c.Variants, variant{Layer: "viking", Title: "Again", MaxZoom: 1})446 }, "captured twice"},447 {"a sibling with no pyramid", captureKind, func(c *capture) {448 c.Variants[0].MaxZoom = 0449 }, "declares no pyramid"},450 {"a feature off the planet", captureKind, func(c *capture) {451 c.Features[0].Latitude = 91452 }, "sits at latitude"},453 }454 for _, tt := range tests {455 t.Run(tt.name, func(t *testing.T) {456 raw := wellFormed()457 tt.spoil(&raw)458 store := writeArchive(t, tt.kind, raw)459 _, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))460 if err == nil || !strings.Contains(err.Error(), tt.says) {461 t.Fatalf("the capture was read anyway: %v", err)462 }463 })464 }465}