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 / mapgenie / mapgenie_test.go
mapgenie_test.go 11.8K
1package mapgenie23import (4 "encoding/json"5 "log/slog"6 "os"7 "path/filepath"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/atlas/format/semconv"12 "github.com/FelineStateMachine/atlas/internal/generate/archive"13 "github.com/FelineStateMachine/atlas/internal/generate/doc"14)1516func TestNormalizeHexColor(t *testing.T) {17 tests := []struct {18 name, in, want string19 }{20 {"six digits", "6984F2", "#6984F2"},21 {"six digits with a hash", "#6984f2", "#6984F2"},22 {"padded", " 6984F2 ", "#6984F2"},23 {"three digits", "abc", "#ABC"},24 {"four digits", "abcd", "#ABCD"},25 {"eight digits", "aabbccdd", "#AABBCCDD"},26 {"five digits is no colour", "abcde", ""},27 {"a word is no colour", "orange", ""},28 {"empty", "", ""},29 }30 for _, tt := range tests {31 t.Run(tt.name, func(t *testing.T) {32 if got := normalizeHexColor(tt.in); got != tt.want {33 t.Errorf("normalizeHexColor(%q) = %q, want %q", tt.in, got, tt.want)34 }35 })36 }37}3839func TestFirstColor(t *testing.T) {40 tests := []struct {41 name, category, group, want string42 }{43 {"the category decides", "ff0000", "00ff00", "#FF0000"},44 {"its group answers for it", "", "00ff00", "#00FF00"},45 {"a malformed category falls back", "nope", "00ff00", "#00FF00"},46 {"neither", "", "", ""},47 }48 for _, tt := range tests {49 t.Run(tt.name, func(t *testing.T) {50 if got := firstColor(tt.category, tt.group); got != tt.want {51 t.Errorf("firstColor(%q, %q) = %q, want %q", tt.category, tt.group, got, tt.want)52 }53 })54 }55}5657// TestNumber pins the one tolerance this reader owes its captures: MapGenie58// spells coordinates as numbers on some maps and as quoted strings on others.59func TestNumber(t *testing.T) {60 tests := []struct {61 name string62 raw string63 want float6464 bad bool65 }{66 {"a number", `0.78796967592872`, 0.78796967592872, false},67 {"a quoted number", `"0.78796967592872"`, 0.78796967592872, false},68 {"a negative", `-0.77144622802734`, -0.77144622802734, false},69 {"padded", " 1.5 ", 1.5, false},70 {"prose", `"north"`, 0, true},71 {"nothing", ``, 0, true},72 }73 for _, tt := range tests {74 t.Run(tt.name, func(t *testing.T) {75 got, err := number(json.RawMessage(tt.raw))76 if tt.bad {77 if err == nil {78 t.Fatalf("number(%s) = %v, want a refusal", tt.raw, got)79 }80 return81 }82 if err != nil {83 t.Fatalf("number(%s): %v", tt.raw, err)84 }85 if got != tt.want {86 t.Errorf("number(%s) = %v, want %v", tt.raw, got, tt.want)87 }88 })89 }90}9192func TestOptionalNumber(t *testing.T) {93 tests := []struct {94 name string95 raw string96 want float6497 present bool98 }{99 {"a number", `12`, 12, true},100 {"null", `null`, 0, false},101 {"absent", ``, 0, false},102 }103 for _, tt := range tests {104 t.Run(tt.name, func(t *testing.T) {105 got, present, err := optionalNumber(json.RawMessage(tt.raw))106 if err != nil {107 t.Fatalf("optionalNumber(%s): %v", tt.raw, err)108 }109 if present != tt.present || got != tt.want {110 t.Errorf("optionalNumber(%s) = %v, %t, want %v, %t", tt.raw, got, present, tt.want, tt.present)111 }112 })113 }114}115116// TestResolveLinks pins what happens to the publisher's prose. Nothing may ship117// a live URL, and a link that pointed at another pin of this same world becomes118// something the reader can follow instead.119func TestResolveLinks(t *testing.T) {120 tests := []struct {121 name string122 description string123 wantText string124 wantLinks []doc.Link125 }{126 {127 name: "prose with no link is untouched",128 description: "A shrine on the hill.",129 wantText: "A shrine on the hill.",130 },131 {132 name: "a link to another pin becomes a cross-reference",133 description: "See [the well](https://mapgenie.io/tunic/maps/world?locationIds=200).",134 wantText: "See the well.",135 wantLinks: []doc.Link{{Title: "the well", Feature: 200}},136 },137 {138 name: "a link to a pin this world does not hold keeps only its label",139 description: "See [elsewhere](https://mapgenie.io/x?locationIds=999).",140 wantText: "See elsewhere.",141 },142 {143 name: "a link to itself is not a cross-reference",144 description: "See [here](https://mapgenie.io/x?locationIds=100).",145 wantText: "See here.",146 },147 {148 // The guard against image syntax reads the matched label, and the149 // match begins at the bracket, so a bare "![alt](url)" is read as150 // an ordinary link whose label happens to follow a bang. This is151 // the reference build's behaviour, recorded rather than corrected:152 // the shape that actually occurs in captures is the nested one153 // below, and that is the one the guard was written for.154 name: "a bang before a link is only a bang",155 description: "![shot](https://mapgenie.io/x?locationIds=200)",156 wantText: "!shot",157 wantLinks: []doc.Link{{Title: "shot", Feature: 200}},158 },159 {160 name: "an image wrapped in a link is not a cross-reference",161 description: "[![alt](picture.png)](https://mapgenie.io/x?locationIds=200)",162 wantText: "![alt](picture.png)",163 },164 {165 name: "a bare URL is removed outright",166 description: "A shrine (https://example.com/guide) on the hill.",167 wantText: "A shrine on the hill.",168 },169 {170 name: "a label may hold one bracketed aside",171 description: "[Oh Baby! [Super Sledge]](https://mapgenie.io/x?locationIds=200)",172 wantText: "Oh Baby! [Super Sledge]",173 wantLinks: []doc.Link{{Title: "Oh Baby! [Super Sledge]", Feature: 200}},174 },175 }176177 for _, tt := range tests {178 t.Run(tt.name, func(t *testing.T) {179 collections := []doc.Collection{{180 Kind: doc.KindPoint,181 Features: []doc.Feature{182 {ID: 100, Description: tt.description},183 {ID: 200, Title: "The well"},184 },185 }}186 resolveLinks(collections)187 got := collections[0].Features[0]188 if got.Description != tt.wantText {189 t.Errorf("description %q, want %q", got.Description, tt.wantText)190 }191 if len(got.Links) != len(tt.wantLinks) {192 t.Fatalf("links %v, want %v", got.Links, tt.wantLinks)193 }194 for i, want := range tt.wantLinks {195 if got.Links[i] != want {196 t.Errorf("link %d is %+v, want %+v", i, got.Links[i], want)197 }198 }199 })200 }201}202203// TestCollectionsOf walks one hand-written capture into the interchange204// document, so the shape rules -- legend order, regions gathered under a205// collection of their own, an empty shape dropped -- are readable in one place.206func TestCollectionsOf(t *testing.T) {207 var raw rawMap208 if err := json.Unmarshal([]byte(`{209 "id": 1, "slug": "world", "title": "World",210 "config": {"tile_sets": [{"name": "Default", "path": "g/world/v1"}]},211 "game": {"slug": "game", "title": "Game"},212 "groups": [213 {"title": "Locations", "color": "6984F2", "categories": [214 {"id": 10, "title": "Shrines", "icon": "shrine", "visible": true,215 "locations": [{"id": 100, "title": "One", "latitude": 1, "longitude": 2, "region_id": 900}]},216 {"id": 11, "title": "Labels", "display_type": "text", "visible": false,217 "color": "FF0000", "locations": []}218 ]}219 ],220 "regions": [221 {"id": 900, "title": "North", "parent_region_id": 901,222 "center_x": 5, "center_y": 6,223 "features": [{"geometry": {"type": "Polygon", "coordinates": [[[0,0]]]}}]},224 {"id": 902, "title": "Nothing", "features": [{"geometry": {"type": "", "coordinates": null}}]}225 ]226 }`), &raw); err != nil {227 t.Fatal(err)228 }229 collections, err := collectionsOf(raw)230 if err != nil {231 t.Fatalf("collectionsOf: %v", err)232 }233 if len(collections) != 3 {234 t.Fatalf("%d collections, want the two categories and the regions", len(collections))235 }236237 shrines := collections[0]238 if shrines.ID != 10 || shrines.Title != "Shrines" || shrines.Group != "Locations" ||239 shrines.Kind != doc.KindPoint || shrines.Icon != "shrine" || !shrines.Visible {240 t.Errorf("shrines is %+v", shrines)241 }242 if shrines.Color != "#6984F2" {243 t.Errorf("shrines took colour %q, want its group's", shrines.Color)244 }245 if shrines.Attrs[semconv.KeyRenderAs] != semconv.RenderAsPin {246 t.Errorf("shrines renders as %q, want a pin", shrines.Attrs[semconv.KeyRenderAs])247 }248 if len(shrines.Features) != 1 || shrines.Features[0].Member != 900 {249 t.Errorf("shrines holds %+v", shrines.Features)250 }251 if shrines.Features[0].At == nil || shrines.Features[0].At.Lat != 1 || shrines.Features[0].At.Lng != 2 {252 t.Errorf("the shrine stands at %+v", shrines.Features[0].At)253 }254255 labels := collections[1]256 if labels.Attrs[semconv.KeyRenderAs] != semconv.RenderAsText {257 t.Errorf("a text category renders as %q", labels.Attrs[semconv.KeyRenderAs])258 }259 if labels.Color != "#FF0000" {260 t.Errorf("a category with its own colour took %q", labels.Color)261 }262 if labels.Visible {263 t.Errorf("a hidden category came through visible")264 }265266 regions := collections[2]267 if regions.Key != regionsKey || regions.Kind != doc.KindArea || !regions.Visible {268 t.Errorf("the regions collection is %+v", regions)269 }270 if regions.ID != 0 {271 t.Errorf("the regions collection claimed id %d; numbering is composition's", regions.ID)272 }273 if len(regions.Features) != 1 {274 t.Fatalf("%d regions, want the one that draws something", len(regions.Features))275 }276 north := regions.Features[0]277 if north.ID != 900 || north.Parent != 901 {278 t.Errorf("north is %+v", north)279 }280 if north.Center == nil || north.Center.Lat != 6 || north.Center.Lng != 5 {281 t.Errorf("north's centre is %+v; the capture spells it x then y", north.Center)282 }283}284285func TestCollectionsOfRefusesLines(t *testing.T) {286 var raw rawMap287 if err := json.Unmarshal([]byte(`{288 "id": 1, "regions": [{"id": 900, "title": "A trail",289 "features": [{"geometry": {"type": "MultiLineString", "coordinates": [[[0,0]]]}}]}]290 }`), &raw); err != nil {291 t.Fatal(err)292 }293 if _, err := collectionsOf(raw); err == nil || !strings.Contains(err.Error(), "not ground") {294 t.Fatalf("a line among the regions was accepted: %v", err)295 }296}297298// TestTranslateRefusesAForeignKind is the source gate: reading another source's299// bytes through this reader would produce a document that lies about where it300// came from.301func TestTranslateRefusesAForeignKind(t *testing.T) {302 root := writeArchive(t, "trek-map", `{"id":1,"slug":"world","config":{"tile_sets":[{"name":"D","path":"p"}]}}`)303 store, err := archive.Open(root)304 if err != nil {305 t.Fatal(err)306 }307 _, err = New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))308 if err == nil || !strings.Contains(err.Error(), "answers only for") {309 t.Fatalf("a foreign capture was read: %v", err)310 }311}312313// TestTranslateSkipsAWorldWithNoRaster: a world with no tile set is not ready,314// not broken. An archive is filled by hand and a half-crawled map is ordinary.315func TestTranslateSkipsAWorldWithNoRaster(t *testing.T) {316 root := writeArchive(t, "map", `{"id":1,"slug":"world","config":{"tile_sets":[]}}`)317 store, err := archive.Open(root)318 if err != nil {319 t.Fatal(err)320 }321 _, err = New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))322 if err == nil || !strings.Contains(err.Error(), "not ready") {323 t.Fatalf("want a not-ready refusal, got %v", err)324 }325}326327// writeArchive lays out the smallest archive holding one volume of one world.328func writeArchive(t *testing.T, kind, capture string) string {329 t.Helper()330 root := t.TempDir()331 worldDir := filepath.Join(root, "games", "g-1", "maps", "world-1")332 mustWrite(t, filepath.Join(root, "archive.json"),333 `{"games":[{"directory":"games/g-1","id":1,"title":"Game"}]}`)334 mustWrite(t, filepath.Join(root, "games", "g-1", "game.json"),335 `{"id":1,"title":"Game","maps":[{"directory":"games/g-1/maps/world-1","id":1,"slug":"world","title":"World"}]}`)336 mustWrite(t, filepath.Join(worldDir, "snapshots", "index.json"),337 `[{"capturedAt":"2026-01-01T00:00:00Z","contentHash":"h","kind":"`+kind+`","sourceId":1,"sourceUrl":"/x"}]`)338 mustWrite(t, filepath.Join(worldDir, "snapshots", "map", "h.json"), capture)339 return root340}341342func mustWrite(t *testing.T, path, body string) {343 t.Helper()344 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {345 t.Fatal(err)346 }347 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {348 t.Fatal(err)349 }350}