clone

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

mgdoc+translators: declared collections

Dami committed d39f89d
A region that was really a line used to prove it only by its geometry: the
generator sniffed MultiLineString at normalize time and the viewer sniffed it
again at draw time. Now the producer says it up front. The mgdoc document
gains an optional atlas_collections list -- key, title, and the collection's
conventions as registered attrs -- and each region an atlas_collection key
naming the declaration it belongs to. No real MapGenie capture spells either
field, so every existing snapshot reads exactly as before and its regions
keep folding into the implicit region collection.

The arcgis translator, the only producer of regions, declares one collection
per zone-making dataset in curated order: the kind from the dataset's
geometry, the stroke width for line datasets, and a label policy from the new
Dataset.Label curation. The four national hydro layers -- watersheds,
subwatersheds, streams, waterbodies -- declare quiet: the nation's water is
the city's context, not its headline, and ninety-seven creek names have no
business shouting over the zoning. The per-zone stroke attr still rides each
line zone because the v2 wire and today's frontend read it there; it leaves
at flag day.

The generator buckets declared regions under their declarations, refuses a
region claiming a collection nobody declared, and refuses lines in the
implicit collection outright -- the sniff is deleted, not relocated. Declared
path collections must say their stroke width, every declaration a valid
geometry kind, and declaration attrs validate as collection attrs in
speakConventions. The v2 wire emits shape collections concatenated in
declaration order, which is byte-for-byte the raw region order the wire
always carried: the Westminster bundle rebuilds to the identical stamp.
commit
d39f89df01c363626d10f6bca4795da4834a5c2f
parent
b8595f20a1749ae8b0c7c87ca55c4a158b320478
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.1 of 3, Jev's estimate
7 files changed, +354 -32
Minternal/arcgismap/arcgismap.go46++++++++++++-
Minternal/arcgismap/arcgismap_test.go65++++++++++++++++++
Minternal/arcgismap/national.go11++-
Minternal/mgdoc/mgdoc.go29++++++-
Mtools/generate/main.go40++++++++---
Mtools/generate/normalize.go84++++++++++++++++++++---
Mtools/generate/normalize_test.go111++++++++++++++++++++++++++++++
M internal/arcgismap/arcgismap.go +43 -3
@@ -80,9 +80,13 @@ type Dataset struct {
8080 8181 // Zones. StrokeWidth is the ground width a line dataset's zones are8282 // drawn and clicked at, in world pixels of the 8192 square: the path83- // stays the line it is, and the viewer strokes it.83+ // stays the line it is, and the viewer strokes it. Label is the label84+ // policy the dataset's collection declares: "quiet" keeps the zones'85+ // names off the map until something highlights them, and empty means86+ // the format's default -- always, for areas.8487 ZoneOf func(Fields) ZoneKey8588 StrokeWidth float6489+ Label string8690 8791 // Basemap. Emphasis scales a stroke per feature -- an arterial wider8892 // than a lane -- and absent means every feature draws alike.
@@ -582,8 +586,9 @@ func Translate(doc []byte) ([]byte, error) {
582586 Title: title(capture.Title, city.Title),583587 Slug: capture.City,584588 },585- Groups: groups,586- Regions: regions,589+ Groups: groups,590+ Regions: regions,591+ Collections: declaredCollections(byOrder),587592 }588593 return json.Marshal(out)589594 }
@@ -750,6 +755,38 @@ func buildGroups(capture *Capture, pairs []pairing, ids *mgdoc.IDSpace) ([]mgdoc
750755 return groups, nil751756 }752757 758+// declaredCollections spells the zone-making datasets as the collections759+// their zones belong to, in curated order: the table already knows each760+// dataset's kind, stroke and label policy, and the declaration says it in761+// the registered vocabulary so the generator never has to sniff geometry to762+// learn what a region is.763+func declaredCollections(pairs []pairing) []mgdoc.CollectionDecl {764+ var out []mgdoc.CollectionDecl765+ for _, pair := range pairs {766+ curated := pair.curated767+ if curated.ZoneOf == nil {768+ continue769+ }770+ kind := semconv.GeometryArea771+ if curated.Geometry == "line" {772+ kind = semconv.GeometryPath773+ }774+ attrs := map[string]string{semconv.KeyGeometryKind: kind}775+ if curated.Geometry == "line" {776+ attrs[semconv.KeyStrokeWidthPx] = strconv.FormatFloat(curated.StrokeWidth, 'f', -1, 64)777+ }778+ if curated.Label != "" {779+ attrs[semconv.KeyLabelPolicy] = curated.Label780+ }781+ out = append(out, mgdoc.CollectionDecl{782+ Key: curated.Slug,783+ Title: curated.Title,784+ Attrs: attrs,785+ })786+ }787+ return out788+}789+753790 // zoneLimit is the most zones one dataset may make. The viewer's zone index754791 // renders every zone a button and a title; an uncurated explosion -- every755792 // parcel a zone -- is refused while the mistake is one table edit old.
@@ -797,6 +834,9 @@ func buildRegions(capture *Capture, pairs []pairing, ids *mgdoc.IDSpace, hydro *
797834 ID: zoneID,798835 Title: scrub(key.Title),799836 Subtitle: scrub(title(key.Subtitle, curated.Title)),837+ // The zone says which declared collection it belongs838+ // to, which is the whole reason declarations exist.839+ Collection: curated.Slug,800840 }801841 // A zone made of lines declares its ground width, so a802842 // reader can draw the path as the one stroke it is.
M internal/arcgismap/arcgismap_test.go +65 -0
@@ -243,6 +243,63 @@ func TestTranslateShapesTheDocument(t *testing.T) {
243243 }244244 }245245 246+ // The document declares its zone-making datasets as collections, in the247+ // same curated order, each saying its kind -- and its stroke, when its248+ // features are lines -- in the registered vocabulary. The hydro layers249+ // declare quiet labels: the nation's water is the city's context, not250+ // its headline.251+ wantDecls := []struct{ key, title, kind, label string }{252+ {"mpo-boundary", "MPO Boundary", "area", ""},253+ {"zoning", "Zoning", "area", ""},254+ {"annexations", "Annexations", "area", ""},255+ {"wetlands", "Wetlands", "area", ""},256+ {"trails", "Paths & Trails", "path", ""},257+ {"watersheds", "Watersheds", "area", "quiet"},258+ {"subwatersheds", "Subwatersheds", "area", "quiet"},259+ {"streams", "Streams", "path", "quiet"},260+ {"waterbodies", "Waterbodies", "area", "quiet"},261+ }262+ if len(out.Collections) != len(wantDecls) {263+ t.Fatalf("document declares %d collections, want %d: %+v",264+ len(out.Collections), len(wantDecls), out.Collections)265+ }266+ for at, want := range wantDecls {267+ decl := out.Collections[at]268+ if decl.Key != want.key || decl.Title != want.title {269+ t.Fatalf("collection %d is %q titled %q, want %q titled %q",270+ at, decl.Key, decl.Title, want.key, want.title)271+ }272+ if err := semconv.Validate(semconv.EntityCollection, decl.Attrs); err != nil {273+ t.Fatalf("collection %q attrs: %v", decl.Key, err)274+ }275+ if got := decl.Attrs[semconv.KeyGeometryKind]; got != want.kind {276+ t.Fatalf("collection %q declares kind %q, want %q", decl.Key, got, want.kind)277+ }278+ if got := decl.Attrs[semconv.KeyLabelPolicy]; got != want.label {279+ t.Fatalf("collection %q declares label policy %q, want %q", decl.Key, got, want.label)280+ }281+ _, stroked := decl.Attrs[semconv.KeyStrokeWidthPx]282+ if stroked != (want.kind == "path") {283+ t.Fatalf("collection %q: only path collections declare a stroke, got %v", decl.Key, decl.Attrs)284+ }285+ }286+ if out.Collections[4].Attrs[semconv.KeyStrokeWidthPx] != "12" ||287+ out.Collections[7].Attrs[semconv.KeyStrokeWidthPx] != "10" {288+ t.Fatalf("path collections declare strokes %q and %q",289+ out.Collections[4].Attrs[semconv.KeyStrokeWidthPx],290+ out.Collections[7].Attrs[semconv.KeyStrokeWidthPx])291+ }292+293+ // Every region claims the collection its dataset declared.294+ wantHomes := []string{"mpo-boundary", "zoning", "zoning", "annexations",295+ "wetlands", "trails", "watersheds", "subwatersheds", "subwatersheds",296+ "streams", "waterbodies"}297+ for at, home := range wantHomes {298+ if got := out.Regions[at].Collection; got != home {299+ t.Fatalf("zone %q claims collection %q, want %q", out.Regions[at].Title, got, home)300+ }301+ }302+246303 // The national zones say which grain of the nation they are.247304 if got := out.Regions[6].Subtitle; got != "Watershed · HUC 1707030101" {248305 t.Fatalf("watershed subtitle is %q", got)
@@ -534,6 +591,14 @@ func TestCuratedTableIsSound(t *testing.T) {
534591 if dataset.Geometry == "line" && dataset.ZoneOf != nil && dataset.StrokeWidth <= 0 {535592 t.Fatalf("dataset %q zones lines without a ribbon", dataset.Slug)536593 }594+ // A label policy, when curated, must be a word the registry595+ // admits, because it rides the declaration verbatim.596+ if dataset.Label != "" {597+ if err := semconv.Validate(semconv.EntityCollection,598+ map[string]string{semconv.KeyLabelPolicy: dataset.Label}); err != nil {599+ t.Fatalf("dataset %q label: %v", dataset.Slug, err)600+ }601+ }537602 }538603 }539604 }
M internal/arcgismap/national.go +9 -2
@@ -30,7 +30,10 @@ const (
3030 // its ground drains through, in two grains of the USGS hydrologic unit3131 // hierarchy, and the named streams and waterbodies that do the draining.3232 // A padded city window intersects a handful of hydrologic units and tens33-// of named streams -- national extent, city-sized answers.33+// of named streams -- national extent, city-sized answers. Every national34+// layer labels quiet: the hydrology is the city's context, not its35+// headline, and a map flooded with creek names is a map about the wrong36+// thing.3437 var National = []Dataset{3538 {3639 Slug: "watersheds", Title: "Watersheds",
@@ -39,6 +42,7 @@ var National = []Dataset{
3942 Keep: []string{"objectid", "huc10", "name"},4043 IDOf: hucID("huc10"),4144 ZoneOf: hucZone("huc10", "Watershed"),45+ Label: "quiet",4246 },4347 {4448 Slug: SlugSubwatersheds, Title: "Subwatersheds",
@@ -47,6 +51,7 @@ var National = []Dataset{
4751 Keep: []string{"objectid", "huc12", "name"},4852 IDOf: hucID("huc12"),4953 ZoneOf: hucZone("huc12", "Subwatershed"),54+ Label: "quiet",5055 },5156 {5257 Slug: "streams", Title: "Streams",
@@ -64,6 +69,7 @@ var National = []Dataset{
6469 return ZoneKey{Key: slugify(name), Title: name, Subtitle: "Stream"}6570 },6671 StrokeWidth: 10,72+ Label: "quiet",6773 Role: "water",6874 },6975 {
@@ -80,7 +86,8 @@ var National = []Dataset{
8086 }8187 return ZoneKey{Key: slugify(name), Title: name, Subtitle: "Waterbody"}8288 },83- Role: "water",89+ Label: "quiet",90+ Role: "water",8491 },8592 }8693
M internal/mgdoc/mgdoc.go +24 -5
@@ -41,9 +41,24 @@ type Map struct {
4141 Game Game `json:"game"`4242 Groups []Group `json:"groups"`4343 Regions []Region `json:"regions"`44+ Collections []CollectionDecl `json:"atlas_collections,omitempty"`4445 Attrs map[string]string `json:"atlas_attrs,omitempty"`4546 }4647 48+// CollectionDecl names a curated collection the map's regions may claim: a49+// translator that knows its regions come in kinds -- the zoning districts, the50+// streams -- declares each kind once and lets every region say which it is.51+// Attrs carry the collection's conventions -- its geometry kind, its label52+// policy, the stroke width its paths draw at -- under the same registered keys53+// everything else speaks. The field rides a name no real MapGenie capture54+// spells, so an untranslated snapshot simply declares nothing and its regions55+// fold into the map's implicit region collection as they always have.56+type CollectionDecl struct {57+ Key string `json:"key"`58+ Title string `json:"title"`59+ Attrs map[string]string `json:"atlas_attrs,omitempty"`60+}61+4762 // Region is a named piece of ground: the generator reads it into a zone, and4863 // its features carry GeoJSON-shaped polygon geometry whose positions are the4964 // same synthetic longitude and latitude a pin speaks. Sources without ground
@@ -58,11 +73,15 @@ type Region struct {
5873 // what a place is -- deferred by the generator into the text payload5974 // the way a pin's description is, so naming ground stays cheap and6075 // explaining it costs only the reader who asks.61- Description string `json:"description,omitempty"`62- CenterX *float64 `json:"center_x,omitempty"`63- CenterY *float64 `json:"center_y,omitempty"`64- Features []RegionFeature `json:"features,omitempty"`65- Attrs map[string]string `json:"atlas_attrs,omitempty"`76+ Description string `json:"description,omitempty"`77+ CenterX *float64 `json:"center_x,omitempty"`78+ CenterY *float64 `json:"center_y,omitempty"`79+ Features []RegionFeature `json:"features,omitempty"`80+ // Collection is the key of the declared collection this region belongs81+ // to. A region naming nothing folds into the map's implicit region82+ // collection, which is every region a real MapGenie capture holds.83+ Collection string `json:"atlas_collection,omitempty"`84+ Attrs map[string]string `json:"atlas_attrs,omitempty"`6685 }6786 6887 type RegionFeature struct {
M tools/generate/main.go +29 -11
@@ -45,17 +45,28 @@ type snapshotIndex struct {
4545 }4646 4747 type rawMap struct {48- ID int64 `json:"id"`49- Title string `json:"title"`50- Slug string `json:"slug"`51- InitialLatitude float64 `json:"initial_latitude"`52- InitialLongitude float64 `json:"initial_longitude"`53- InitialZoom float64 `json:"initial_zoom"`54- Config rawConfig `json:"config"`55- Game rawGame `json:"game"`56- Groups []rawGroup `json:"groups"`57- Regions []rawRegion `json:"regions"`58- Attrs map[string]string `json:"atlas_attrs"`48+ ID int64 `json:"id"`49+ Title string `json:"title"`50+ Slug string `json:"slug"`51+ InitialLatitude float64 `json:"initial_latitude"`52+ InitialLongitude float64 `json:"initial_longitude"`53+ InitialZoom float64 `json:"initial_zoom"`54+ Config rawConfig `json:"config"`55+ Game rawGame `json:"game"`56+ Groups []rawGroup `json:"groups"`57+ Regions []rawRegion `json:"regions"`58+ Collections []rawCollectionDecl `json:"atlas_collections"`59+ Attrs map[string]string `json:"atlas_attrs"`60+}61+62+// rawCollectionDecl is a translator's declaration of a curated collection its63+// regions may claim (mgdoc.CollectionDecl on the producing side). MapGenie64+// captures never spell the field, so their regions keep folding into the65+// implicit collection.66+type rawCollectionDecl struct {67+ Key string `json:"key"`68+ Title string `json:"title"`69+ Attrs map[string]string `json:"atlas_attrs"`5970 }6071 6172 type rawGame struct {
@@ -112,6 +123,7 @@ type rawRegion struct {
112123 CenterX json.RawMessage `json:"center_x"`113124 CenterY json.RawMessage `json:"center_y"`114125 Features []rawFeature `json:"features"`126+ Collection string `json:"atlas_collection"`115127 Attrs map[string]string `json:"atlas_attrs"`116128 }117129
@@ -524,6 +536,12 @@ func speakConventions(game *catalogVolume) error {
524536 for collectionIndex := range m.Collections {525537 collection := &m.Collections[collectionIndex]526538 if collection.Kind != kindPoint {539+ // A declared collection's attributes are collection540+ // attributes in the registry's own terms; the implicit541+ // collection carries none and passes trivially.542+ if err := semconv.Validate(semconv.EntityCollection, collection.Attrs); err != nil {543+ return fmt.Errorf("world %s collection %q: %w", m.Slug, collection.Title, err)544+ }527545 for _, shape := range collection.Features {528546 if err := validateShapeAttrs(shape.Attrs); err != nil {529547 return fmt.Errorf("world %s zone %q: %w", m.Slug, shape.Title, err)
M tools/generate/normalize.go +73 -11
@@ -1,6 +1,10 @@
11 package main22 3-import "fmt"3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/atlas/internal/semconv"7+)48 59 // A map's contents used to mirror the MapGenie shape all the way through the610 // generator: pins under groups and categories, regions standing apart as
@@ -77,7 +81,11 @@ type feature struct {
7781 7882 // normalizeWorld folds a decoded capture into the uniform model: each7983 // category becomes a point collection carrying its resolved colours, and80-// every region becomes a shape feature of one implicit collection. A region84+// every region becomes a shape feature of the collection it claims -- a85+// declared one when the region names it, the implicit region collection when86+// it names nothing. Declared collections follow the point collections in87+// declaration order, the implicit collection last, and a region naming a88+// collection nobody declared fails the build rather than guessing. A region8189 // whose geometry all came through empty is dropped here, exactly as it always8290 // was, because a shape with nothing to draw is not a shape.8391 func normalizeWorld(raw rawMap) ([]worldCollection, error) {
@@ -119,7 +127,34 @@ func normalizeWorld(raw rawMap) ([]worldCollection, error) {
119127 collections = append(collections, collection)120128 }121129 }122- regions := worldCollection{130+ declared := make([]worldCollection, 0, len(raw.Collections))131+ declaredAt := make(map[string]int, len(raw.Collections))132+ for _, decl := range raw.Collections {133+ if decl.Key == "" || decl.Key == regionsCollectionKey {134+ return nil, fmt.Errorf("collection %q declares key %q, which the implicit collection reserves",135+ decl.Title, decl.Key)136+ }137+ if _, doubled := declaredAt[decl.Key]; doubled {138+ return nil, fmt.Errorf("collection %q is declared twice", decl.Key)139+ }140+ kind := decl.Attrs[semconv.KeyGeometryKind]141+ if kind != kindPath && kind != kindArea {142+ return nil, fmt.Errorf("collection %q declares geometry kind %q; a declared collection must say %q or %q",143+ decl.Key, kind, kindPath, kindArea)144+ }145+ if _, stroked := decl.Attrs[semconv.KeyStrokeWidthPx]; kind == kindPath && !stroked {146+ return nil, fmt.Errorf("path collection %q declares no %s; a path is a line and a width",147+ decl.Key, semconv.KeyStrokeWidthPx)148+ }149+ declaredAt[decl.Key] = len(declared)150+ declared = append(declared, worldCollection{151+ Key: decl.Key,152+ Title: decl.Title,153+ Kind: kind,154+ Attrs: decl.Attrs,155+ })156+ }157+ implicit := worldCollection{123158 Key: regionsCollectionKey,124159 Title: "Regions",125160 Kind: kindArea,
@@ -144,17 +179,40 @@ func normalizeWorld(raw rawMap) ([]worldCollection, error) {
144179 if hasX && hasY {145180 shape.Center = &coordinate{Latitude: centerY, Longitude: centerX}146181 }182+ home := &implicit183+ if rawRegion.Collection != "" {184+ at, known := declaredAt[rawRegion.Collection]185+ if !known {186+ return nil, fmt.Errorf("region %q claims collection %q, which the map never declares",187+ rawRegion.Title, rawRegion.Collection)188+ }189+ home = &declared[at]190+ }147191 for _, part := range rawRegion.Features {148- if part.Geometry.Type != "" && len(part.Geometry.Coordinates) > 0 {149- shape.Geometry = append(shape.Geometry, part.Geometry)192+ if part.Geometry.Type == "" || len(part.Geometry.Coordinates) == 0 {193+ continue150194 }195+ // The geometry sniff is dead: a line belongs to a declared path196+ // collection, which says so up front, and never to the implicit197+ // collection, whose regions are ground.198+ if home == &implicit && part.Geometry.Type == "MultiLineString" {199+ return nil, fmt.Errorf(200+ "region %q draws lines in the implicit region collection; lines must be declared as a path collection",201+ rawRegion.Title)202+ }203+ shape.Geometry = append(shape.Geometry, part.Geometry)151204 }152205 if len(shape.Geometry) > 0 {153- regions.Features = append(regions.Features, shape)206+ home.Features = append(home.Features, shape)154207 }155208 }156- if len(regions.Features) > 0 {157- collections = append(collections, regions)209+ for _, collection := range declared {210+ if len(collection.Features) > 0 {211+ collections = append(collections, collection)212+ }213+ }214+ if len(implicit.Features) > 0 {215+ collections = append(collections, implicit)158216 }159217 return collections, nil160218 }
@@ -206,12 +264,16 @@ func (m catalogWorld) v2Groups() []catalogGroup {
206264 return groups207265 }208266 209-// v2Zones rebuilds the wire's zone list from the implicit region collection,210-// in the order the capture told them.267+// v2Zones rebuilds the wire's zone list from the shape collections, each268+// collection's features in the order the capture told them and the269+// collections in declaration order. That concatenation is exactly the raw270+// region order: the one producer that declares collections emits its regions271+// grouped by dataset in the same curated order it declares them, and the272+// implicit collection -- last here -- is the whole list everywhere else.211273 func (m catalogWorld) v2Zones() []zone {212274 var zones []zone213275 for _, collection := range m.Collections {214- if collection.Key != regionsCollectionKey {276+ if collection.Kind == kindPoint {215277 continue216278 }217279 for _, f := range collection.Features {
M tools/generate/normalize_test.go +111 -0
@@ -3,7 +3,10 @@ package main
33 import (44 "encoding/json"55 "reflect"6+ "strings"67 "testing"8+9+ "github.com/FelineStateMachine/atlas/internal/semconv"710 )811 912 // The collections model only earns its place if the v2 wire can be rebuilt
@@ -101,3 +104,111 @@ func TestNormalizeWorldRoundTripsTheV2Shapes(t *testing.T) {
101104 last.Key, last.Kind, regionsCollectionKey, kindArea)102105 }103106 }107+108+// declaredFixture is a capture that declares its collections: an area109+// collection and a path collection, a region claiming each, and one region110+// claiming nothing, which still folds into the implicit collection.111+func declaredFixture() rawMap {112+ ring := json.RawMessage(`[[[[0,0],[1,0],[1,1]]]]`)113+ lines := json.RawMessage(`[[[0,0],[1,1]]]`)114+ return rawMap{115+ Collections: []rawCollectionDecl{116+ {Key: "districts", Title: "Districts", Attrs: map[string]string{117+ semconv.KeyGeometryKind: "area",118+ semconv.KeyLabelPolicy: "quiet",119+ }},120+ {Key: "creeks", Title: "Creeks", Attrs: map[string]string{121+ semconv.KeyGeometryKind: "path",122+ semconv.KeyStrokeWidthPx: "10",123+ }},124+ },125+ Regions: []rawRegion{126+ {ID: 1, Title: "R-5", Collection: "districts",127+ Features: []rawFeature{{Geometry: geometry{Type: "MultiPolygon", Coordinates: ring}}}},128+ {ID: 2, Title: "Big Dry Creek", Collection: "creeks",129+ Features: []rawFeature{{Geometry: geometry{Type: "MultiLineString", Coordinates: lines}}}},130+ {ID: 3, Title: "Old Town",131+ Features: []rawFeature{{Geometry: geometry{Type: "MultiPolygon", Coordinates: ring}}}},132+ },133+ }134+}135+136+// Declared collections bucket the regions that claim them, in declaration137+// order after the point collections, the implicit collection last -- and the138+// v2 zone emission walks that order, which for a producer that declares in139+// its own region order is exactly the raw order the wire always said.140+func TestNormalizeWorldBucketsDeclaredCollections(t *testing.T) {141+ collections, err := normalizeWorld(declaredFixture())142+ if err != nil {143+ t.Fatal(err)144+ }145+ if len(collections) != 3 {146+ t.Fatalf("normalized into %d collections, want 3: %+v", len(collections), collections)147+ }148+ districts, creeks, implicit := collections[0], collections[1], collections[2]149+ if districts.Key != "districts" || districts.Kind != kindArea || districts.Title != "Districts" {150+ t.Fatalf("first collection is %+v", districts)151+ }152+ if districts.Attrs[semconv.KeyLabelPolicy] != "quiet" {153+ t.Fatalf("districts lost its declared attrs: %v", districts.Attrs)154+ }155+ if creeks.Key != "creeks" || creeks.Kind != kindPath {156+ t.Fatalf("second collection is %+v", creeks)157+ }158+ if implicit.Key != regionsCollectionKey || implicit.Kind != kindArea {159+ t.Fatalf("last collection is %+v, want the implicit one", implicit)160+ }161+ for at, want := range []string{"R-5", "Big Dry Creek", "Old Town"} {162+ if len(collections[at].Features) != 1 || collections[at].Features[0].Title != want {163+ t.Fatalf("collection %d holds %+v, want one feature %q",164+ at, collections[at].Features, want)165+ }166+ }167+ m := catalogWorld{Collections: collections}168+ ids := []int64{}169+ for _, z := range m.v2Zones() {170+ ids = append(ids, z.ID)171+ }172+ if !reflect.DeepEqual(ids, []int64{1, 2, 3}) {173+ t.Fatalf("v2 zones emit as %v, want the raw region order 1,2,3", ids)174+ }175+}176+177+func TestNormalizeWorldRefusals(t *testing.T) {178+ refuse := func(name, wantWords string, mutate func(*rawMap)) {179+ t.Helper()180+ raw := declaredFixture()181+ mutate(&raw)182+ _, err := normalizeWorld(raw)183+ if err == nil {184+ t.Fatalf("%s: normalized anyway", name)185+ }186+ if !strings.Contains(err.Error(), wantWords) {187+ t.Fatalf("%s: error %q says nothing of %q", name, err, wantWords)188+ }189+ }190+ refuse("undeclared collection", "never declares", func(raw *rawMap) {191+ raw.Regions[0].Collection = "ghost"192+ })193+ // The sniff is dead: an undeclared line is refused, not guessed at.194+ refuse("implicit lines", "declared as a path collection", func(raw *rawMap) {195+ raw.Regions[1].Collection = ""196+ })197+ refuse("path without stroke", "stroke", func(raw *rawMap) {198+ delete(raw.Collections[1].Attrs, semconv.KeyStrokeWidthPx)199+ })200+ refuse("kind unspoken", "geometry kind", func(raw *rawMap) {201+ delete(raw.Collections[0].Attrs, semconv.KeyGeometryKind)202+ })203+ refuse("kind foreign", "geometry kind", func(raw *rawMap) {204+ raw.Collections[0].Attrs[semconv.KeyGeometryKind] = "point"205+ })206+ refuse("key doubled", "declared twice", func(raw *rawMap) {207+ raw.Collections[1].Key = "districts"208+ raw.Regions[1].Collection = "districts"209+ })210+ refuse("key reserved", "reserves", func(raw *rawMap) {211+ raw.Collections[0].Key = regionsCollectionKey212+ raw.Regions[0].Collection = regionsCollectionKey213+ })214+}