1package bundle_test23import (4 "bytes"5 "os"6 "path/filepath"7 "strings"8 "testing"910 "github.com/FelineStateMachine/atlas/format/bundle"11 "github.com/FelineStateMachine/atlas/format/semconv"12)1314// payload assembles a world payload around a collections array, so each case15// varies one thing.16func payload(collections string) string {17 return "{" + fixtureLenses + `,"collections":[` + collections + `]}`18}1920const (21 line = `{"type":"MultiLineString","coordinates":[[[0,0],[1,1]]]}`22 polygon = `{"type":"MultiPolygon","coordinates":[[[[0,0],[1,0],[1,1],[0,0]]]]}`23)2425// The structural promises of the v3 wire, one broken at a time. Everything26// here is a producer error: a reader that meets one has been handed a bundle27// nobody validated.28func TestValidateHoldsAWorldToItsPromises(t *testing.T) {29 cases := []struct {30 name string31 world fixtureWorld32 icons []string33 mention string34 }{35 {36 name: "a sound point world",37 world: fixtureWorld{slug: "overworld", locations: []bundle.Location{{ID: 1, Title: "Origin"}}},38 },39 {40 name: "a sound area world",41 world: fixtureWorld{slug: "overworld", areas: 1, detail: payload(42 `{"id":9,"title":"Districts","kind":"area","visible":true,` +43 `"features":[{"id":5,"title":"R-5","geometry":[` + polygon + `]}]}`)},44 },45 {46 name: "a sound path world",47 world: fixtureWorld{slug: "overworld", paths: 1, detail: payload(48 `{"id":9,"title":"Creeks","kind":"path","visible":true,` +49 `"attrs":{"atlas.stroke.width_px":"10"},` +50 `"features":[{"id":5,"title":"Big Dry Creek","geometry":[` + line + `]}]}`)},51 },52 {53 name: "a payload carrying a runtime URL",54 world: fixtureWorld{slug: "overworld", locations: []bundle.Location{{ID: 1}},55 text: `{"1":{"d":"see https://example.invalid/x"}}`},56 mention: "runtime URL",57 },58 {59 name: "a payload linking over plain http",60 world: fixtureWorld{slug: "overworld", detail: payload(61 `{"id":1,"title":"Marker","kind":"point","visible":true,` +62 `"icon":"http://example.invalid/pin.svg"}`)},63 mention: "runtime URL",64 },65 {66 name: "a manifest promising more points than are packed",67 world: fixtureWorld{slug: "overworld", points: 7, countsStated: true,68 locations: []bundle.Location{{ID: 1}}},69 mention: "7",70 },71 {72 name: "a manifest counting areas the payload does not hold",73 world: fixtureWorld{slug: "overworld", areas: 2, detail: payload(74 `{"id":9,"title":"Districts","kind":"area","visible":true,` +75 `"features":[{"id":5,"title":"R-5","geometry":[` + polygon + `]}]}`)},76 mention: "manifest says",77 },78 {79 name: "geometry disagreeing with the declared kind",80 world: fixtureWorld{slug: "overworld", areas: 1, detail: payload(81 `{"id":9,"title":"Districts","kind":"area","visible":true,` +82 `"features":[{"id":5,"title":"R-5","geometry":[` + line + `]}]}`)},83 mention: "inlines a MultiLineString",84 },85 {86 name: "a path collection with no stroke width",87 world: fixtureWorld{slug: "overworld", paths: 1, detail: payload(88 `{"id":9,"title":"Creeks","kind":"path","visible":true,` +89 `"features":[{"id":5,"title":"Big Dry Creek","geometry":[` + line + `]}]}`)},90 mention: "declares no atlas.stroke.width_px",91 },92 {93 name: "a label policy on a path collection",94 world: fixtureWorld{slug: "overworld", paths: 1, detail: payload(95 `{"id":9,"title":"Creeks","kind":"path","visible":true,` +96 `"attrs":{"atlas.stroke.width_px":"10","atlas.label.policy":"quiet"},` +97 `"features":[{"id":5,"title":"Big Dry Creek","geometry":[` + line + `]}]}`)},98 mention: "label policy",99 },100 {101 name: "a point collection carrying inline features",102 world: fixtureWorld{slug: "overworld", detail: payload(103 `{"id":1,"title":"Marker","kind":"point","visible":true,` +104 `"features":[{"id":5,"title":"Stray","geometry":[]}]}`)},105 mention: "inline features",106 },107 {108 name: "a collection of no known kind",109 world: fixtureWorld{slug: "overworld", detail: payload(110 `{"id":1,"title":"Marker","kind":"blob","visible":true}`)},111 mention: "none of point, path, area",112 },113 {114 name: "two collections sharing an id",115 world: fixtureWorld{slug: "overworld", detail: payload(116 `{"id":1,"title":"Marker","kind":"point","visible":true},` +117 `{"id":1,"title":"Other","kind":"point","visible":true}`)},118 mention: "share id 1",119 },120 {121 name: "a collection naming an icon that is not there",122 world: fixtureWorld{slug: "overworld", detail: payload(123 `{"id":1,"title":"Marker","kind":"point","visible":true,"iconAsset":"absent.svg"}`)},124 mention: "missing icon",125 },126 {127 name: "a location owned by no collection at all",128 world: fixtureWorld{slug: "overworld",129 locations: []bundle.Location{{ID: 1, Owner: 4}},130 detail: payload(`{"id":1,"title":"Marker","kind":"point","visible":true}`)},131 mention: "no point collection",132 },133 {134 name: "a location owned by an area collection",135 world: fixtureWorld{slug: "overworld", areas: 1,136 locations: []bundle.Location{{ID: 1, Owner: 0}},137 detail: payload(`{"id":9,"title":"Districts","kind":"area","visible":true,` +138 `"features":[{"id":5,"title":"R-5","geometry":[` + polygon + `]}]}`)},139 mention: "no point collection",140 },141 {142 name: "a world with no lenses at all",143 world: fixtureWorld{slug: "overworld", detail: `{"lenses":[],"collections":[]}`},144 mention: "no lenses",145 },146 {147 name: "a lens naming fewer formats than it has levels",148 world: fixtureWorld{slug: "overworld", detail: `{"lenses":[{"tiles":"overworld",` +149 `"minZoom":0,"maxZoom":2,"formats":["jpg"]}],"collections":[]}`},150 mention: "tile formats",151 },152 {153 name: "a lens claiming a level that holds no tiles",154 world: fixtureWorld{slug: "overworld", detail: `{"lenses":[{"tiles":"overworld",` +155 `"minZoom":0,"maxZoom":1,"formats":["jpg","jpg"]}],"collections":[]}`},156 mention: "empty tile level 1",157 },158 {159 name: "a payload that is not JSON",160 world: fixtureWorld{slug: "overworld", detail: `{not json`},161 mention: "decode payload",162 },163 }164 for _, test := range cases {165 t.Run(test.name, func(t *testing.T) {166 reader := fixture{worlds: []fixtureWorld{test.world}, icons: test.icons}.open(t, t.TempDir())167 err := reader.Validate()168 if test.mention == "" {169 if err != nil {170 t.Fatalf("a sound bundle is refused: %v", err)171 }172 return173 }174 if err == nil || !strings.Contains(err.Error(), test.mention) {175 t.Fatalf("validated with %v, want a complaint about %q", err, test.mention)176 }177 })178 }179}180181// The conventions half of validation is asymmetric by design: a bundle that182// declares a vocabulary is held to it, and one that declares none is held to183// nothing.184func TestValidateIsStrictOnlyAboutWhatIsDeclared(t *testing.T) {185 unregistered := payload(`{"id":1,"title":"Marker","kind":"point","visible":true,` +186 `"attrs":{"atlas.render.like":"pin"}}`)187188 silent := fixture{worlds: []fixtureWorld{{slug: "overworld", detail: unregistered}}}189 if err := silent.open(t, t.TempDir()).Validate(); err != nil {190 t.Errorf("a bundle declaring no conventions was held to them: %v", err)191 }192 declaring := silent.conventional()193 if err := declaring.open(t, t.TempDir()).Validate(); err == nil ||194 !strings.Contains(err.Error(), "not registered") {195 t.Errorf("a declaring bundle escaped the registry: %v", err)196 }197}198199func TestValidateHoldsDeclaredConventions(t *testing.T) {200 sphere := func(attrs string) string {201 return `{` + fixtureLenses + `,"collections":[],"attrs":` + attrs + `}`202 }203 cases := []struct {204 name string205 world fixtureWorld206 mention string207 }{208 {209 name: "a fully declared sphere",210 world: fixtureWorld{slug: "overworld", detail: sphere(211 `{"atlas.geometry.surface":"sphere","atlas.geometry.projection":"equirect",` +212 `"atlas.geometry.equirect.px":"0,0,8192,4096",` +213 `"atlas.geometry.equirect.deg":"-180,90,180,-90"}`)},214 },215 {216 name: "a sphere with no projection",217 world: fixtureWorld{slug: "overworld", detail: sphere(`{"atlas.geometry.surface":"sphere"}`)},218 mention: "no projection",219 },220 {221 name: "a sphere whose mapping does not parse",222 world: fixtureWorld{slug: "overworld", detail: sphere(223 `{"atlas.geometry.surface":"sphere","atlas.geometry.projection":"equirect",` +224 `"atlas.geometry.equirect.px":"0,0,0,4096",` +225 `"atlas.geometry.equirect.deg":"-180,90,180,-90"}`)},226 mention: "no area",227 },228 {229 name: "a world attribute on a collection",230 world: fixtureWorld{slug: "overworld", detail: payload(231 `{"id":1,"title":"Marker","kind":"point","visible":true,` +232 `"attrs":{"atlas.geometry.surface":"plane"}}`)},233 mention: "attaches to",234 },235 {236 name: "a declared kind disagreeing with the wire",237 world: fixtureWorld{slug: "overworld", detail: payload(238 `{"id":1,"title":"Marker","kind":"point","visible":true,` +239 `"attrs":{"atlas.geometry.kind":"area"}}`)},240 mention: "while its attributes say",241 },242 {243 name: "a standard icon that was never resolved",244 world: fixtureWorld{slug: "overworld", detail: payload(245 `{"id":1,"title":"Marker","kind":"point","visible":true,` +246 `"attrs":{"atlas.icon.std":"maki/mountain"}}`)},247 mention: "never resolved",248 },249 {250 name: "an inline feature carrying a collection's attribute",251 world: fixtureWorld{slug: "overworld", areas: 1, detail: payload(252 `{"id":9,"title":"Districts","kind":"area","visible":true,` +253 `"features":[{"id":5,"title":"R-5","attrs":{"atlas.render.as":"pin"},` +254 `"geometry":[` + polygon + `]}]}`)},255 mention: "attaches to",256 },257 {258 name: "a text entry carrying an unregistered attribute",259 world: fixtureWorld{slug: "overworld", locations: []bundle.Location{{ID: 1}},260 text: `{"1":{"d":"words","a":{"atlas.geo.height":"5"}}}`},261 mention: "not registered",262 },263 {264 name: "a text entry with a well-formed attribute",265 world: fixtureWorld{slug: "overworld", locations: []bundle.Location{{ID: 1}},266 text: `{"1":{"d":"words","a":{"atlas.geo.lat":"-42.4301"}}}`},267 },268 {269 name: "a text payload that is not JSON",270 world: fixtureWorld{slug: "overworld", locations: []bundle.Location{{ID: 1}},271 text: `{not json`},272 mention: "decode text",273 },274 }275 for _, test := range cases {276 t.Run(test.name, func(t *testing.T) {277 reader := (fixture{worlds: []fixtureWorld{test.world}}).conventional().open(t, t.TempDir())278 err := reader.Validate()279 if test.mention == "" {280 if err != nil {281 t.Fatalf("a sound bundle is refused: %v", err)282 }283 return284 }285 if err == nil || !strings.Contains(err.Error(), test.mention) {286 t.Fatalf("validated with %v, want a complaint about %q", err, test.mention)287 }288 })289 }290}291292func TestValidateCatchesAWorldThatIsNotThere(t *testing.T) {293 // Two worlds written and listed validates; the same bundle with a third294 // world listed and not written does not.295 reader := fixture{296 worlds: []fixtureWorld{297 {slug: "overworld", locations: []bundle.Location{{ID: 1}}},298 {slug: "underworld", locations: []bundle.Location{{ID: 2}}},299 },300 }.open(t, t.TempDir())301 if err := reader.Validate(); err != nil {302 t.Fatalf("two written worlds fail validation: %v", err)303 }304305 manifest := soundManifest()306 manifest.Worlds = append(manifest.Worlds, bundle.WorldEntry{Slug: "underworld", Title: "Underworld"})307 path := filepath.Join(t.TempDir(), "promised.atlas")308 file, err := os.Create(path)309 if err != nil {310 t.Fatal(err)311 }312 writer, err := bundle.NewWriter(file, manifest)313 if err != nil {314 t.Fatal(err)315 }316 must(t, writer.AddDeflated(bundle.WorldEntryName("overworld", bundle.WorldSuffix),317 []byte(payload(`{"id":1,"title":"Marker","kind":"point","visible":true}`))))318 must(t, writer.AddStored(bundle.WorldEntryName("overworld", bundle.PackedSuffix),319 bytes.NewReader(bundle.PackLocations(nil))))320 must(t, writer.AddDeflated(bundle.WorldEntryName("overworld", bundle.TextSuffix), []byte("{}")))321 must(t, writer.AddStored(bundle.TilesPrefix+"overworld/0/0/0.jpg", bytes.NewReader([]byte("raster"))))322 must(t, writer.Close())323 must(t, file.Close())324325 promised, err := bundle.Open(path)326 if err != nil {327 t.Fatal(err)328 }329 defer promised.Close()330 if err := promised.Validate(); err == nil || !strings.Contains(err.Error(), "underworld") {331 t.Errorf("a promised-but-absent world validated with %v", err)332 }333}334335func TestGeometryFitsKind(t *testing.T) {336 cases := []struct {337 kind, geometry string338 want bool339 }{340 {semconv.GeometryArea, "MultiPolygon", true},341 {semconv.GeometryArea, "Polygon", true},342 {semconv.GeometryArea, "LineString", false},343 {semconv.GeometryPath, "MultiLineString", true},344 {semconv.GeometryPath, "LineString", true},345 {semconv.GeometryPath, "Polygon", false},346 {semconv.GeometryPoint, "Point", false},347 {"blob", "Polygon", false},348 }349 for _, test := range cases {350 if got := bundle.GeometryFitsKind(test.kind, test.geometry); got != test.want {351 t.Errorf("GeometryFitsKind(%q, %q) = %v, want %v", test.kind, test.geometry, got, test.want)352 }353 }354}