1package arcgishub23import (4 "encoding/json"5 "errors"6 "log/slog"7 "os"8 "path/filepath"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/atlas/format/semconv"13 "github.com/FelineStateMachine/atlas/internal/generate/archive"14 "github.com/FelineStateMachine/atlas/internal/generate/doc"15)1617// The capture these tests read is testdata/city.json: one invented crawl day of18// the proof city, as small as a day can be while still exercising what this19// reader owns -- the Mercator window, the curated fold of rows into zones, a20// multipart polygon, a named trail, titled pins, the drawing the basemap is21// rasterized from, and the membership join against a captured subwatershed. It22// is the whole input, so what the assertions below say is exactly what the23// translation of it is.2425// stageCity lays the testdata capture out as the smallest archive holding one26// city of one day, optionally spoiled first.27func stageCity(t *testing.T, spoil func(map[string]any)) *archive.Archive {28 t.Helper()29 body, err := os.ReadFile(filepath.Join("testdata", "city.json"))30 if err != nil {31 t.Fatal(err)32 }33 if spoil != nil {34 var raw map[string]any35 if err := json.Unmarshal(body, &raw); err != nil {36 t.Fatal(err)37 }38 spoil(raw)39 if body, err = json.Marshal(raw); err != nil {40 t.Fatal(err)41 }42 }43 root := t.TempDir()44 worldDir := filepath.Join(root, "games", "g-1", "maps", "world-1")45 mustWrite(t, filepath.Join(root, "archive.json"),46 `{"games":[{"directory":"games/g-1","id":1,"title":"Bend, Oregon","source":"arcgis-hub"}]}`)47 mustWrite(t, filepath.Join(root, "games", "g-1", "game.json"),48 `{"id":1,"title":"Bend, Oregon","maps":[{"directory":"games/g-1/maps/world-1","id":1,"slug":"2026-03-01","title":"2026-03-01"}]}`)49 mustWrite(t, filepath.Join(worldDir, "snapshots", "index.json"),50 `[{"capturedAt":"2026-03-01T12:00:00Z","contentHash":"h","kind":"arcgis-map","sourceId":1,"sourceUrl":"/x"}]`)51 mustWrite(t, filepath.Join(worldDir, "snapshots", "map", "h.json"), string(body))52 store, err := archive.Open(root)53 if err != nil {54 t.Fatal(err)55 }56 return store57}5859func mustWrite(t *testing.T, path, body string) {60 t.Helper()61 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {62 t.Fatal(err)63 }64 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {65 t.Fatal(err)66 }67}6869func translated(t *testing.T) doc.Document {70 t.Helper()71 store := stageCity(t, nil)72 document, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))73 if err != nil {74 t.Fatal(err)75 }76 return document77}7879// claim mints the number one stable name derives to, in a space of its own, so80// a test can say which identity a thing must carry without re-walking the81// reader's own claim order.82func claim(t *testing.T, name string) int64 {83 t.Helper()84 id, err := doc.NewIDSpace().Claim(name)85 if err != nil {86 t.Fatal(err)87 }88 return id89}9091// cityWindowOf is the capture's own window, spelled as this package's type so92// the expectations below run through the very projection under test.93func cityWindowOf() window {94 return window{West: -121.6, North: 44.12, East: -121.1, South: 44.04}95}9697// TestCityTranslatesExactly walks the invented crawl day into the document:98// the day becomes the world, the declared Mercator window becomes the world's99// attributes and every feature's projection, pins come before ground, and the100// curated buckets fold exactly as the table says.101func TestCityTranslatesExactly(t *testing.T) {102 document := translated(t)103104 if document.Volume.Slug != "bend-or" || document.Volume.Title != "Bend, Oregon" {105 t.Errorf("volume %s/%s", document.Volume.Slug, document.Volume.Title)106 }107 if document.Source.IDSpace != doc.IDSpaceDerived {108 t.Errorf("a hub numbers rows with churning object ids, so the id space must be %q, not %q",109 doc.IDSpaceDerived, document.Source.IDSpace)110 }111 if len(document.Icons) != 0 {112 t.Errorf("the document ships %d pieces of artwork; a city's hub publishes none", len(document.Icons))113 }114 world := document.Worlds[0]115116 // The day is the world: slug and title both, because a picker listing dates117 // is the version history reading as itself.118 if world.Slug != "2026-03-01" || world.Title != "2026-03-01" {119 t.Errorf("world %s/%s", world.Slug, world.Title)120 }121 if world.ID != claim(t, "arcgis:map:bend-or/2026-03-01") {122 t.Errorf("world id %d is not the number its stable name mints", world.ID)123 }124125 // The declared window is the whole coordinate design: a plane cut from126 // Earth, spelled exactly as the capture declared it.127 px, deg := cityWindowOf().pxDeg()128 want := map[string]string{129 semconv.KeyGeometrySurface: semconv.SurfacePlane,130 semconv.KeyGeometryBody: "earth",131 semconv.KeyGeometryMercatorPx: px,132 semconv.KeyGeometryMercatorDeg: deg,133 }134 for key, value := range want {135 if world.Attrs[key] != value {136 t.Errorf("world says %s=%q, want %q", key, world.Attrs[key], value)137 }138 }139 if len(world.Attrs) != len(want) {140 t.Errorf("world carries %d attributes %v, want %d", len(world.Attrs), world.Attrs, len(want))141 }142143 // The one picture is drawn, not fetched: the lens carries the rendered144 // pyramid's frame and the drawing its deepest level is rasterized from --145 // the boundary's ring and both trail rows, proposed or not, because a146 // drawing is what the ground looks like rather than what a legend lists.147 if len(world.Lenses) != 1 {148 t.Fatalf("%d lenses", len(world.Lenses))149 }150 lens := world.Lenses[0]151 if lens.Name != "Basemap" || lens.TileSet != TileSetPath("bend-or", "2026-03-01") {152 t.Errorf("lens %s/%s", lens.Name, lens.TileSet)153 }154 if lens.Frame == nil || lens.Frame.MaxZoom != 1 || lens.Frame.Format != "png" {155 t.Fatalf("frame %+v", lens.Frame)156 }157 if window := lens.Frame.Windows["1"]; window != (doc.TileWindow{MaxX: 1, MaxY: 1}) {158 t.Errorf("a rendered level is complete by construction, got window %+v", window)159 }160 if lens.Drawing == nil || lens.Drawing.Zoom != 1 {161 t.Fatalf("the basemap declares drawing %+v; a hub serves geometry and no tiles", lens.Drawing)162 }163 var roles []string164 for _, shape := range lens.Drawing.Shapes {165 roles = append(roles, shape.Role)166 }167 if strings.Join(roles, ",") != "boundary,trail,trail" {168 t.Errorf("the drawing paints %v; curation names the boundary and both trail rows", roles)169 }170171 // Pins first, then ground, in the curation table's own order.172 if len(world.Collections) != 5 {173 t.Fatalf("%d collections, want pins then four grounds", len(world.Collections))174 }175 sites := world.Collections[0]176 if sites.Title != "Historic Resources" || sites.Group != "Heritage" || sites.Kind != doc.KindPoint {177 t.Errorf("the pin collection is %s/%s/%s", sites.Title, sites.Group, sites.Kind)178 }179 if sites.Attrs[semconv.KeyIconStd] != "maki/monument" {180 t.Errorf("the glyph is named for composition to resolve, got %q", sites.Attrs[semconv.KeyIconStd])181 }182 // Three rows were captured; the untitled one is the publisher's hygiene and183 // is left out rather than shipped blank.184 if len(sites.Features) != 2 {185 t.Fatalf("%d pins from three rows, one of them untitled", len(sites.Features))186 }187 mill := sites.Features[0]188 if mill.Title != "Old Mill" || mill.Description != "Mill District" {189 t.Errorf("the first pin is %q described %q", mill.Title, mill.Description)190 }191 if mill.ID != claim(t, "arcgis:loc:bend-or/historic-sites/5") {192 t.Errorf("pin id %d is not the number its stable name mints", mill.ID)193 }194 // The pin stands where the window puts it, and the city's own coordinates195 // travel verbatim beside the synthetic position.196 x, y := cityWindowOf().worldPixel(-121.31, 44.05)197 if at := doc.SyntheticPosition(x, y); mill.At == nil || *mill.At != at {198 t.Errorf("the pin stands at %+v, the window puts -121.31,44.05 at %+v", mill.At, at)199 }200 if mill.Attrs[semconv.KeyGeoLat] != "44.05" || mill.Attrs[semconv.KeyGeoLon] != "-121.31" {201 t.Errorf("the published coordinates travel as %v", mill.Attrs)202 }203204 boundary, zoning, trails, subs := world.Collections[1], world.Collections[2], world.Collections[3], world.Collections[4]205 if boundary.Title != "MPO Boundary" || zoning.Title != "Zoning" ||206 trails.Title != "Paths & Trails" || subs.Title != "Subwatersheds" {207 t.Fatalf("ground reads %s, %s, %s, %s; the table's order is the legend order",208 boundary.Title, zoning.Title, trails.Title, subs.Title)209 }210211 // Zoning: rows fold into buckets by code, buckets emit in sorted key order,212 // and a bucket holds one geometry part per row that drew -- the RS zone is213 // the multipart one, two parts, the first of them two polygons.214 if zoning.Key != "zoning" || zoning.Kind != doc.KindArea {215 t.Errorf("zoning is %s/%s", zoning.Key, zoning.Kind)216 }217 if len(zoning.Features) != 2 || zoning.Features[0].Title != "CB" || zoning.Features[1].Title != "RS" {218 t.Fatalf("zoning holds %+v; buckets emit in sorted key order", zoning.Features)219 }220 rs := zoning.Features[1]221 if rs.ID != claim(t, "arcgis:zone:bend-or/zoning/rs") {222 t.Errorf("zone id %d is not the number its stable name mints", rs.ID)223 }224 if len(rs.Geometry) != 2 || rs.Geometry[0].Type != "MultiPolygon" {225 t.Fatalf("the RS zone draws %d parts of %s; two rows drew, one of them multipart",226 len(rs.Geometry), rs.Geometry[0].Type)227 }228 var multipart [][][][]float64229 if err := json.Unmarshal(rs.Geometry[0].Coordinates, &multipart); err != nil {230 t.Fatal(err)231 }232 if len(multipart) != 2 {233 t.Errorf("the multipart row carries %d polygons, the capture drew two", len(multipart))234 }235 // One position, followed all the way through: true degrees to world pixel236 // to the pair a shape's coordinates speak.237 if corner := cityWindowOf().syntheticRing(-121.45, 44.10); multipart[0][0][0][0] != corner[0] ||238 multipart[0][0][0][1] != corner[1] {239 t.Errorf("the first corner landed at %v, the window puts it at %v", multipart[0][0][0], corner)240 }241242 // Trails: only the named, existing trail earns a zone, clickable end to end243 // as one multipart line, at the stroke width the curation declares.244 if trails.Kind != doc.KindPath || trails.Attrs[semconv.KeyStrokeWidthPx] != "12" {245 t.Errorf("trails are %s at width %q", trails.Kind, trails.Attrs[semconv.KeyStrokeWidthPx])246 }247 if len(trails.Features) != 1 || trails.Features[0].Title != "River Trail" ||248 trails.Features[0].Subtitle != "Riverside" {249 t.Fatalf("trails hold %+v; the proposed row makes no zone", trails.Features)250 }251 if len(trails.Features[0].Geometry) != 1 || trails.Features[0].Geometry[0].Type != "MultiLineString" {252 t.Errorf("the trail draws %+v", trails.Features[0].Geometry)253 }254255 // The national layer labels quiet and buckets by the HUC code, and it makes256 // no claim about itself.257 if subs.Attrs[semconv.KeyLabelPolicy] != semconv.LabelQuiet {258 t.Errorf("subwatersheds label %q; the hydrology is context, not headline",259 subs.Attrs[semconv.KeyLabelPolicy])260 }261 unit := subs.Features[0]262 if unit.Title != "Whychus Creek" || unit.Subtitle != "Subwatershed ยท HUC 170703010801" {263 t.Errorf("the unit reads %q/%q", unit.Title, unit.Subtitle)264 }265 if unit.Attrs[semconv.KeyHydroHUC12] != "" {266 t.Error("a subwatershed does not need a sentence telling it which subwatershed it lies in")267 }268269 // The membership join: every zone whose sampled ground lies wholly in the270 // one captured subwatershed claims it -- the code as the machine-readable271 // key, and one earned sentence for the card.272 for _, zone := range [][2]string{{zoning.Features[0].Title, zoning.Features[0].Attrs[semconv.KeyHydroHUC12]},273 {rs.Title, rs.Attrs[semconv.KeyHydroHUC12]}} {274 if zone[1] != "170703010801" {275 t.Errorf("zone %s claims %q, its ground lies in the captured subwatershed", zone[0], zone[1])276 }277 }278 if rs.Description != "Lies in the Whychus Creek subwatershed (HUC 170703010801)." {279 t.Errorf("the earned sentence reads %q", rs.Description)280 }281282 // The same archived bytes give the same document, identities included.283 again := translated(t)284 if again.Worlds[0].ID != world.ID || again.Worlds[0].Collections[2].Features[1].ID != rs.ID {285 t.Error("two translations of one capture minted different identities")286 }287}288289// TestTranslateRefusesWhatItCannotStandFor states the reader's gates: an290// uncurated city is passed over as not ready -- an operator's own city may sit291// in the archive and the public table may not name it -- while a wrong capture292// is refused outright.293func TestTranslateRefusesWhatItCannotStandFor(t *testing.T) {294 t.Run("an uncurated city is not ready, not broken", func(t *testing.T) {295 store := stageCity(t, func(raw map[string]any) { raw["city"] = "somewhere-else" })296 _, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))297 if !errors.Is(err, archive.ErrNotReady) {298 t.Fatalf("an uncurated city answered %v, want a not-ready pass-over", err)299 }300 })301 t.Run("a world slug that is not a capture day", func(t *testing.T) {302 store := stageCity(t, func(raw map[string]any) { raw["mapSlug"] = "downtown" })303 _, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))304 if err == nil || !strings.Contains(err.Error(), "not a capture day") {305 t.Fatalf("a dateless world was read: %v", err)306 }307 })308 t.Run("a dataset the table does not curate", func(t *testing.T) {309 store := stageCity(t, func(raw map[string]any) {310 datasets := raw["datasets"].([]any)311 raw["datasets"] = append(datasets, map[string]any{"slug": "water-accounts", "features": []any{}})312 })313 _, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))314 if err == nil || !strings.Contains(err.Error(), "not curated") {315 t.Fatalf("an uncurated dataset was read: %v", err)316 }317 })318 t.Run("a point dataset with no titled row", func(t *testing.T) {319 store := stageCity(t, func(raw map[string]any) {320 for _, entry := range raw["datasets"].([]any) {321 dataset := entry.(map[string]any)322 if dataset["slug"] != "historic-sites" {323 continue324 }325 for _, held := range dataset["features"].([]any) {326 row := held.(map[string]any)327 row["fields"] = map[string]any{}328 }329 }330 })331 _, err := New().Translate(store, store.Volumes()[0], slog.New(slog.DiscardHandler))332 if err == nil || !strings.Contains(err.Error(), "no row carries a title") {333 t.Fatalf("a nameless dataset was shipped blank: %v", err)334 }335 })336}