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 / compose / compose_test.go
compose_test.go 11.9K
1package compose23import (4 "encoding/json"5 "strings"6 "testing"78 "github.com/FelineStateMachine/atlas/format/semconv"9 "github.com/FelineStateMachine/atlas/internal/generate/curation"10 "github.com/FelineStateMachine/atlas/internal/generate/doc"11 "github.com/FelineStateMachine/atlas/internal/generate/tiles"12)1314func tables(t *testing.T) curation.Tables {15 t.Helper()16 out, err := curation.Load()17 if err != nil {18 t.Fatal(err)19 }20 return out21}2223func TestOrderWorlds(t *testing.T) {24 worlds := func(names ...string) []composedWorld {25 out := make([]composedWorld, 0, len(names))26 for _, name := range names {27 slug, title, parent := name, name, ""28 if left, right, split := strings.Cut(name, "<"); split {29 slug, title, parent = left, left, right30 }31 out = append(out, composedWorld{Slug: slug, Title: title, Parent: parent})32 }33 return out34 }35 names := func(in []composedWorld) []string {36 out := make([]string, 0, len(in))37 for _, world := range in {38 out = append(out, world.Slug)39 }40 return out41 }4243 tests := []struct {44 name string45 volume string46 in []composedWorld47 want []string48 }{49 {50 name: "unlisted worlds sort by title",51 volume: "tunic",52 in: worlds("c", "a", "b"),53 want: []string{"a", "b", "c"},54 },55 {56 name: "the curated world leads",57 volume: "fallout-new-vegas",58 in: worlds("big-mt", "mojave-wasteland", "zion-canyon"),59 want: []string{"mojave-wasteland", "big-mt", "zion-canyon"},60 },61 {62 name: "a piece follows the sheet it came from",63 volume: "fallout-new-vegas",64 in: worlds("big-mt", "north<mojave-wasteland", "mojave-wasteland"),65 want: []string{"mojave-wasteland", "north", "big-mt"},66 },67 {68 name: "a version history opens on the present",69 volume: "bend-or",70 in: worlds("2024-01-01", "2026-01-01", "2025-01-01"),71 want: []string{"2026-01-01", "2025-01-01", "2024-01-01"},72 },73 }7475 for _, tt := range tests {76 t.Run(tt.name, func(t *testing.T) {77 orderWorlds(tables(t), tt.volume, tt.in)78 got := names(tt.in)79 if len(got) != len(tt.want) {80 t.Fatalf("order %v, want %v", got, tt.want)81 }82 for i := range got {83 if got[i] != tt.want[i] {84 t.Fatalf("order %v, want %v", got, tt.want)85 }86 }87 })88 }89}9091// TestNumberCollections pins the derivation a collection with a key but no92// number gets, and the collision that fails a build rather than renaming93// somebody's collection quietly.94func TestNumberCollections(t *testing.T) {95 world := doc.World{ID: 427, Collections: []doc.Collection{96 {ID: 5984, Title: "Fox Shrine", Kind: doc.KindPoint},97 {Key: "regions", Title: "Regions", Kind: doc.KindArea},98 }}99 got, err := numberCollections(world)100 if err != nil {101 t.Fatal(err)102 }103 if got[0].ID != 5984 {104 t.Errorf("a native number was not passed through: %d", got[0].ID)105 }106 if got[1].ID == 0 || got[1].ID > 0x7fffffff {107 t.Errorf("a derived number is %d, want a positive int31", got[1].ID)108 }109110 // The derivation is stable, and stable across the world it names.111 again, err := numberCollections(world)112 if err != nil {113 t.Fatal(err)114 }115 if again[1].ID != got[1].ID {116 t.Errorf("the derivation moved between runs: %d then %d", got[1].ID, again[1].ID)117 }118 elsewhere, err := numberCollections(doc.World{ID: 428, Collections: world.Collections[1:]})119 if err != nil {120 t.Fatal(err)121 }122 if elsewhere[0].ID == got[1].ID {123 t.Errorf("one key derived one number in two worlds: %d", elsewhere[0].ID)124 }125126 t.Run("a collision fails the build", func(t *testing.T) {127 colliding := doc.World{ID: 427, Collections: []doc.Collection{128 {ID: got[1].ID, Title: "Taken", Kind: doc.KindPoint},129 {Key: "regions", Title: "Regions", Kind: doc.KindArea},130 }}131 if _, err := numberCollections(colliding); err == nil ||132 !strings.Contains(err.Error(), "collides") {133 t.Fatalf("a collision was accepted: %v", err)134 }135 })136137 t.Run("neither a number nor a key", func(t *testing.T) {138 if _, err := numberCollections(doc.World{Collections: []doc.Collection{{Title: "?"}}}); err == nil {139 t.Fatal("a nameless collection was accepted")140 }141 })142}143144// TestSpeakConventions holds the producer-strict gate and what composition145// speaks on a volume's behalf.146func TestSpeakConventions(t *testing.T) {147 world := func(collections ...composedCollection) []composedWorld {148 return []composedWorld{{Slug: "night-city", Collections: collections}}149 }150151 t.Run("kind is mirrored, artwork is declared", func(t *testing.T) {152 worlds := world(composedCollection{153 Title: "Ripperdocs", Kind: doc.KindPoint, Icon: "ripper-doc",154 IconAsset: "ripper-doc.svg",155 Attrs: map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin},156 })157 if err := speakConventions(tables(t), "cyberpunk-2077", worlds); err != nil {158 t.Fatal(err)159 }160 attrs := worlds[0].Collections[0].Attrs161 if attrs[semconv.KeyGeometryKind] != doc.KindPoint {162 t.Errorf("kind is %q", attrs[semconv.KeyGeometryKind])163 }164 if attrs[semconv.KeyIconKind] != semconv.IconKindGlyph {165 t.Errorf("artwork is %q, want a glyph", attrs[semconv.KeyIconKind])166 }167 if attrs[semconv.KeyCollectionKey] != "ripperdoc" {168 t.Errorf("merge identity is %q, want the curated one", attrs[semconv.KeyCollectionKey])169 }170 })171172 t.Run("a picture says so", func(t *testing.T) {173 worlds := world(composedCollection{174 Title: "Shops", Kind: doc.KindPoint, IconAsset: "shops.png", IconPicture: true,175 Attrs: map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin},176 })177 if err := speakConventions(tables(t), "tunic", worlds); err != nil {178 t.Fatal(err)179 }180 if got := worlds[0].Collections[0].Attrs[semconv.KeyIconKind]; got != semconv.IconKindPicture {181 t.Errorf("artwork is %q, want a picture", got)182 }183 })184185 t.Run("what a source said is not overwritten", func(t *testing.T) {186 worlds := world(composedCollection{187 Title: "Labels", Kind: doc.KindPoint,188 Attrs: map[string]string{189 semconv.KeyRenderAs: semconv.RenderAsText,190 semconv.KeyGeometryKind: doc.KindPoint,191 semconv.KeyCollectionKey: "mine",192 },193 Icon: "ripper-doc",194 })195 if err := speakConventions(tables(t), "cyberpunk-2077", worlds); err != nil {196 t.Fatal(err)197 }198 attrs := worlds[0].Collections[0].Attrs199 if attrs[semconv.KeyRenderAs] != semconv.RenderAsText {200 t.Errorf("render-as was overwritten with %q", attrs[semconv.KeyRenderAs])201 }202 if attrs[semconv.KeyCollectionKey] != "mine" {203 t.Errorf("a declared merge identity was overwritten with %q", attrs[semconv.KeyCollectionKey])204 }205 })206207 t.Run("an outset comes from curation", func(t *testing.T) {208 worlds := []composedWorld{{Slug: "skyrim", IconOutset: "dark"}}209 if err := speakConventions(tables(t), "skyrim", worlds); err != nil {210 t.Fatal(err)211 }212 if worlds[0].Attrs[semconv.KeyIconOutset] != semconv.OutsetDark {213 t.Errorf("outset is %q", worlds[0].Attrs[semconv.KeyIconOutset])214 }215 })216217 t.Run("an unregistered key fails the build", func(t *testing.T) {218 worlds := world(composedCollection{219 Title: "Odd", Kind: doc.KindArea,220 //depcheck:allow semconvlit the point of the case is a key the registry does not know, which by definition has no constant221 Attrs: map[string]string{"atlas.made.up": "yes"},222 })223 if err := speakConventions(tables(t), "tunic", worlds); err == nil {224 t.Fatal("an unregistered key was accepted")225 }226 })227228 t.Run("a key on the wrong entity fails the build", func(t *testing.T) {229 worlds := world(composedCollection{230 Title: "Odd", Kind: doc.KindArea,231 Features: []composedFeature{{Feature: doc.Feature{232 Title: "Ground",233 Attrs: map[string]string{semconv.KeyIconOutset: semconv.OutsetDark},234 }}},235 })236 if err := speakConventions(tables(t), "tunic", worlds); err == nil {237 t.Fatal("a world's key on a feature was accepted")238 }239 })240241 t.Run("speaking for one piece does not speak for another", func(t *testing.T) {242 shared := map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin}243 worlds := []composedWorld{244 {Slug: "a", Collections: []composedCollection{{Title: "A", Kind: doc.KindPoint, Attrs: shared}}},245 {Slug: "b", Collections: []composedCollection{{Title: "B", Kind: doc.KindPoint, Attrs: shared}}},246 }247 if err := speakConventions(tables(t), "tunic", worlds); err != nil {248 t.Fatal(err)249 }250 if _, leaked := shared[semconv.KeyGeometryKind]; leaked {251 t.Error("composition wrote through a shared attribute map")252 }253 })254}255256func TestContentExtent(t *testing.T) {257 grid := surfaceGrid{SourceZoom: 5, FirstTile: 0, TileSize: 256, Size: 8192}258 window := tiles.Box{Width: grid.Size, Height: grid.Size}259 at := func(lat, lng float64) composedFeature {260 return composedFeature{Feature: doc.Feature{At: &doc.Position{Lat: lat, Lng: lng}}}261 }262263 t.Run("nothing to measure", func(t *testing.T) {264 if _, ok := contentExtent(nil, 0, window, grid); ok {265 t.Error("an empty world reported an extent")266 }267 })268269 t.Run("a margin surrounds the outermost feature", func(t *testing.T) {270 collections := []composedCollection{{Kind: doc.KindPoint, Features: []composedFeature{271 at(0, 0), at(0, 0.1),272 }}}273 box, ok := contentExtent(collections, 0, window, grid)274 if !ok {275 t.Fatal("no extent")276 }277 // Two points a hair apart: the margin floor decides the size.278 if box.Width < 2*int(surfaceMarginFloor) {279 t.Errorf("box %+v is narrower than two margins", box)280 }281 })282283 t.Run("a feature off the sheet is left out of the reckoning", func(t *testing.T) {284 inside := []composedCollection{{Kind: doc.KindPoint, Features: []composedFeature{at(0, 0)}}}285 withStray := []composedCollection{{Kind: doc.KindPoint, Features: []composedFeature{286 at(0, 0), at(85, 179),287 }}}288 small, _ := contentExtent(inside, 0, tiles.Box{X: 4000, Y: 4000, Width: 200, Height: 200}, grid)289 large, _ := contentExtent(withStray, 0, tiles.Box{X: 4000, Y: 4000, Width: 200, Height: 200}, grid)290 if small != large {291 t.Errorf("a feature outside the window changed the extent: %+v then %+v", small, large)292 }293 })294295 t.Run("a shard measures only its own", func(t *testing.T) {296 collections := []composedCollection{{Kind: doc.KindPoint, Features: []composedFeature{297 {Feature: doc.Feature{At: &doc.Position{}}, Shard: 1},298 {Feature: doc.Feature{At: &doc.Position{Lat: 40, Lng: 40}}, Shard: 2},299 }}}300 one, _ := contentExtent(collections, 1, window, grid)301 both, _ := contentExtent(collections, 0, window, grid)302 if one == both {303 t.Errorf("a shard measured the whole world: %+v", one)304 }305 })306}307308func TestFlattenCoordinates(t *testing.T) {309 tests := []struct {310 name string311 raw string312 want int313 }{314 {"a polygon ring", `[[[1,2],[3,4],[5,6]]]`, 3},315 {"a multipolygon", `[[[[1,2],[3,4]]],[[[5,6]]]]`, 3},316 {"a line", `[[1,2],[3,4]]`, 2},317 {"nothing", `null`, 0},318 {"not coordinates", `{"a":1}`, 0},319 }320 for _, tt := range tests {321 t.Run(tt.name, func(t *testing.T) {322 if got := flattenCoordinates(json.RawMessage(tt.raw)); len(got) != tt.want {323 t.Errorf("flattened %d positions, want %d", len(got), tt.want)324 }325 })326 }327}328329func TestBoxes(t *testing.T) {330 a := tiles.Box{X: 0, Y: 0, Width: 10, Height: 10}331 b := tiles.Box{X: 5, Y: 5, Width: 10, Height: 10}332 apart := tiles.Box{X: 100, Y: 100, Width: 1, Height: 1}333334 if got := unionBox(a, b); got != (tiles.Box{X: 0, Y: 0, Width: 15, Height: 15}) {335 t.Errorf("union is %+v", got)336 }337 if got := intersectBox(a, b); got != (tiles.Box{X: 5, Y: 5, Width: 5, Height: 5}) {338 t.Errorf("intersection is %+v", got)339 }340 if got := intersectBox(a, apart); got != apart {341 t.Errorf("rectangles that do not meet give %+v, want the second", got)342 }343}344345// TestOptionalID pins the one translation between the document's spelling of346// absence and the wire's.347func TestOptionalID(t *testing.T) {348 if optionalID(0) != nil {349 t.Error("zero should be absent on the wire")350 }351 if got := optionalID(7); got == nil || *got != 7 {352 t.Errorf("optionalID(7) = %v", got)353 }354}355356func TestIsPicture(t *testing.T) {357 tests := []struct {358 file string359 want bool360 }{361 {"a.png", true},362 {"a.svg", false},363 {"", false},364 {"png", false},365 }366 for _, tt := range tests {367 if got := isPicture(tt.file); got != tt.want {368 t.Errorf("isPicture(%q) = %t", tt.file, got)369 }370 }371}