clone

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

feat/atlas-next-horizon
atlas / internal / app / app_test.go
app_test.go 74.5K
1package app_test23import (4 "bytes"5 "context"6 "crypto/sha256"7 "encoding/json"8 "errors"9 "fmt"10 "io"11 "mime"12 "net/http"13 "net/http/httptest"14 "net/url"15 "path"16 "strings"17 "testing"1819 "github.com/FelineStateMachine/atlas/format/bundle"20 "github.com/FelineStateMachine/atlas/format/vnext"21 "github.com/FelineStateMachine/atlas/internal/app"22 "github.com/FelineStateMachine/atlas/internal/app/hostenv"23 "github.com/FelineStateMachine/atlas/internal/minting"24)2526// The handler is tested through a host that is entirely in memory, which is27// the point of hostenv: nothing here needs a directory, a bundle file, or a28// window, so what these tests exercise is the handler and not the machine.2930type fakeVolume struct {31 manifest bundle.Manifest32 entries map[string][]byte33 featurePageCalls int34 demandAddressed bool35}3637func (v *fakeVolume) Info() hostenv.VolumeInfo {38 manifest := v.manifest39 info := hostenv.VolumeInfo{40 Slug: manifest.Volume.Slug, Title: manifest.Volume.Title, Stamp: manifest.Version.Stamp,41 Release: vnext.Release{Title: manifest.Volume.Title, CreatedAt: manifest.Version.CreatedAt, Revision: manifest.Version.Revision, Stamp: manifest.Version.Stamp, Worlds: len(manifest.Worlds)},42 TileGrid: hostenv.TileGrid{SourceZoom: manifest.TileGrid.SourceZoom, FirstTile: manifest.TileGrid.FirstTile, TileSize: manifest.TileGrid.TileSize, Size: manifest.TileGrid.Size},43 }44 for _, world := range manifest.Worlds {45 info.Worlds = append(info.Worlds, hostenv.WorldInfo{Slug: world.Slug, Title: world.Title, Parent: world.Parent, Points: world.Points, Paths: world.Paths, Areas: world.Areas, UpdatedAt: world.UpdatedAt})46 }47 return info48}4950func (v *fakeVolume) semantic() vnext.Volume {51 semantic, _ := vnext.ImportV3Volume(v.manifest, func(name string) ([]byte, error) { return v.Blob(name) })52 for name, data := range v.entries {53 if !strings.HasPrefix(name, bundle.IconsPrefix) {54 continue55 }56 semantic.Assets = append(semantic.Assets, vnext.Asset{57 ID: strings.TrimPrefix(name, bundle.IconsPrefix), MediaType: mime.TypeByExtension(path.Ext(name)), Data: data,58 })59 }60 for index := range semantic.Assets {61 digest := sha256.Sum256(semantic.Assets[index].Data)62 semantic.Assets[index].Path = fmt.Sprintf("assets/%x", digest)63 }64 return semantic65}6667func (v *fakeVolume) Outline() vnext.Volume {68 semantic := v.semantic()69 for worldIndex := range semantic.Worlds {70 for setIndex := range semantic.Worlds[worldIndex].FeatureSets {71 semantic.Worlds[worldIndex].FeatureSets[setIndex].Features = nil72 }73 }74 return semantic75}7677func (v *fakeVolume) nativeReader() (*vnext.Reader, error) {78 compiled, err := vnext.Compile(v.semantic())79 if err != nil {80 return nil, err81 }82 compiled.Release.CreatedAt = v.manifest.Version.CreatedAt83 if compiled.Release.CreatedAt == "" {84 compiled.Release.CreatedAt = "2026-01-01T00:00:00Z"85 }86 var data bytes.Buffer87 if err := vnext.Write(&data, compiled); err != nil {88 return nil, err89 }90 return vnext.Open(bytes.NewReader(data.Bytes()), int64(data.Len()), vnext.StandardSchema())91}9293func (v *fakeVolume) FeaturePage(request vnext.FeaturePageRequest) (vnext.FeaturePageResult, error) {94 v.featurePageCalls++95 reader, err := v.nativeReader()96 if err != nil {97 return vnext.FeaturePageResult{}, err98 }99 return reader.FeaturePage(request)100}101102func (v *fakeVolume) FeatureSetSummaries() ([]vnext.FeatureSetSummary, error) {103 var out []vnext.FeatureSetSummary104 for _, world := range v.semantic().Worlds {105 for _, set := range world.FeatureSets {106 out = append(out, vnext.FeatureSetSummary{FeatureSet: set.ID, Rows: len(set.Features)})107 }108 }109 return out, nil110}111112func (v *fakeVolume) DemandAddressedFeatures() bool { return v.demandAddressed }113114func (v *fakeVolume) RasterTile(name string) (vnext.RasterTile, error) {115 data, held := v.entries[name]116 if !held {117 return vnext.RasterTile{}, errors.New("no such tile")118 }119 return vnext.RasterTile{Name: name, MediaType: mime.TypeByExtension(path.Ext(name)), Data: data}, nil120}121122func (v *fakeVolume) Blob(name string) ([]byte, error) {123 held, ok := v.entries[name]124 if ok {125 return held, nil126 }127 compiled, err := vnext.Compile(v.semantic())128 if err == nil {129 for _, blob := range compiled.Blobs {130 if blob.Name == name {131 return blob.Data, nil132 }133 }134 }135 return nil, errors.New("no such entry")136}137138func (v *fakeVolume) Schema() ([]byte, error) { return vnext.StandardSchema().Canonical() }139func (v *fakeVolume) TableBlock(name string) ([]byte, error) {140 compiled, err := vnext.Compile(v.semantic())141 if err != nil {142 return nil, err143 }144 hash, err := compiled.Schema.Hash()145 if err != nil {146 return nil, err147 }148 for _, table := range compiled.Tables {149 if table.Name == name {150 return vnext.EncodeBlock(hash, table.Table)151 }152 }153 return nil, errors.New("no typed fixture")154}155156type fakeVolumes struct {157 volumes []hostenv.Volume158 location string159160 // arriving is what the next Install adds, and installs is what it was161 // asked to take in. already makes it the successful import that copied162 // nothing, because the library was holding this exact build already.163 arriving hostenv.Volume164 installs [][]byte165 refuse error166 already bool167}168169func (s *fakeVolumes) Volumes() []hostenv.Volume { return s.volumes }170func (s *fakeVolumes) Location() string { return s.location }171172func (s *fakeVolumes) Rescan() ([]string, error) { return nil, nil }173174func (s *fakeVolumes) Install(name string, content io.Reader) (hostenv.Installed, error) {175 data, err := io.ReadAll(content)176 if err != nil {177 return hostenv.Installed{}, err178 }179 s.installs = append(s.installs, data)180 if s.refuse != nil {181 return hostenv.Installed{}, s.refuse182 }183 if s.arriving == nil {184 return hostenv.Installed{}, errors.New("nothing to install")185 }186 info := s.arriving.Info()187 s.volumes = append(s.volumes, s.arriving)188 return hostenv.Installed{189 Slug: info.Slug,190 Title: info.Title,191 Stamp: info.Stamp,192 Already: s.already,193 Changed: []string{info.Slug},194 }, nil195}196197type fakeHost struct {198 volumes *fakeVolumes199 sessions hostenv.SessionStore200 pick func(ctx context.Context) (io.ReadCloser, string, error)201}202203func (h *fakeHost) Volumes() hostenv.VolumeStore { return h.volumes }204func (h *fakeHost) Sessions() hostenv.SessionStore { return h.sessions }205206func (h *fakeHost) PickFile(ctx context.Context) (io.ReadCloser, string, error) {207 if h.pick == nil {208 return nil, "", hostenv.ErrNotAvailable209 }210 return h.pick(ctx)211}212213// volume builds one serving volume with the entries a data-plane test asks214// for.215func volume(slug, title, stamp string, worlds ...bundle.WorldEntry) *fakeVolume {216 if len(worlds) == 0 {217 worlds = []bundle.WorldEntry{{Slug: "overworld", Title: "Overworld", Points: 3, UpdatedAt: "2026-01-01T00:00:00Z"}}218 }219 return &fakeVolume{220 manifest: bundle.Manifest{221 Format: bundle.Format,222 FormatVersion: bundle.FormatVersion,223 Conventions: 2,224 Volume: bundle.Volume{Slug: slug, Title: title},225 Version: bundle.Version{Stamp: stamp, CreatedAt: "2026-01-01T00:00:00Z"},226 TileGrid: bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192},227 Worlds: worlds,228 },229 entries: map[string][]byte{230 "worlds/overworld.json": []byte(`{"lenses":[],"collections":[]}`),231 "worlds/overworld.bin": bundle.PackLocations(nil),232 "worlds/overworld.text": []byte(`{}`),233 "icons/marker.svg": []byte("<svg/>"),234 "tiles/overworld/0/0/0.jpg": []byte("raster"),235 },236 }237}238239const (240 tunicStamp = "13d5657ed9038808e5fe12ef44e769b40297e9d720e53376f430f224128f2dfc"241 marsStamp = "68e141f26b1a8808e5fe12ef44e769b40297e9d720e53376f430f224128f2dfc"242)243244func newApp(t *testing.T, volumes ...hostenv.Volume) (*app.App, *fakeHost) {245 t.Helper()246 host := &fakeHost{247 volumes: &fakeVolumes{volumes: volumes, location: "/library"},248 sessions: hostenv.NewMemorySessions(),249 }250 return app.New(host, app.Options{}), host251}252253type fakeMinter struct {254 request minting.Request255 preview minting.Preview256 result minting.Result257 err error258 onMint func()259}260261func (m *fakeMinter) Default() minting.Request { return m.request }262263func (m *fakeMinter) Preview(request minting.Request) (minting.Preview, error) {264 m.request = request265 return m.preview, m.err266}267268func (m *fakeMinter) Mint(_ context.Context, request minting.Request, emit func(minting.Event)) (minting.Result, error) {269 m.request = request270 if emit != nil {271 emit(minting.Event{Stage: "publish", Message: "native build installed"})272 }273 if m.onMint != nil {274 m.onMint()275 }276 return m.result, m.err277}278279func get(t *testing.T, handler http.Handler, path string, headers map[string]string) *httptest.ResponseRecorder {280 t.Helper()281 request := httptest.NewRequest(http.MethodGet, path, nil)282 for name, value := range headers {283 request.Header.Set(name, value)284 }285 recorder := httptest.NewRecorder()286 handler.ServeHTTP(recorder, request)287 return recorder288}289290func post(t *testing.T, handler http.Handler, path string, form url.Values) *httptest.ResponseRecorder {291 t.Helper()292 request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))293 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")294 recorder := httptest.NewRecorder()295 handler.ServeHTTP(recorder, request)296 return recorder297}298299func TestCatalogComposition(t *testing.T) {300 handler, _ := newApp(t,301 volume("tunic", "TUNIC", tunicStamp),302 volume("mars", "Mars", marsStamp))303304 got := get(t, handler, "/data/catalog.json", nil)305 if got.Code != http.StatusOK {306 t.Fatalf("catalog answered %d", got.Code)307 }308 if cache := got.Header().Get("Cache-Control"); cache != "no-store" {309 t.Errorf("catalog Cache-Control = %q, want no-store: it is the one response whose job is to be current", cache)310 }311 if kind := got.Header().Get("Content-Type"); kind != "application/json" {312 t.Errorf("catalog Content-Type = %q", kind)313 }314315 var catalog struct {316 Volumes []struct {317 Slug string `json:"slug"`318 Title string `json:"title"`319 Stamp string `json:"stamp"`320 Base string `json:"base"`321 Worlds []struct {322 Slug string `json:"slug"`323 } `json:"worlds"`324 } `json:"volumes"`325 BundlesDir string `json:"bundlesDir"`326 }327 if err := json.Unmarshal(got.Body.Bytes(), &catalog); err != nil {328 t.Fatal(err)329 }330 if len(catalog.Volumes) != 2 {331 t.Fatalf("catalog lists %d volumes", len(catalog.Volumes))332 }333 // Volumes are listed by title, which is the order a reader meets them in.334 if catalog.Volumes[0].Slug != "mars" || catalog.Volumes[1].Slug != "tunic" {335 t.Errorf("catalog order is %s, %s; want by title",336 catalog.Volumes[0].Slug, catalog.Volumes[1].Slug)337 }338 if want := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp); catalog.Volumes[1].Base != want {339 t.Errorf("base = %q, want %q", catalog.Volumes[1].Base, want)340 }341 if catalog.BundlesDir != "/library" {342 t.Errorf("bundlesDir = %q, want the store's own location", catalog.BundlesDir)343 }344}345346func TestContentPlane(t *testing.T) {347 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))348 base := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp)349 markerDigest := sha256.Sum256([]byte("<svg/>"))350 marker := fmt.Sprintf("%s/assets/%x", base, markerDigest)351352 served := []struct {353 name string354 path string355 kind string356 body string357 }{358 {"the native schema", base + "/schema.json", "application/schema+json", ""},359 {"a native typed table", base + "/data/volumes.pack", "application/vnd.atlas.table", ""},360 {"a content-addressed asset", marker, "image/svg+xml", "<svg/>"},361 {"a tile", base + "/tiles/overworld/0/0/0.jpg", "image/jpeg", "raster"},362 }363364 outline := get(t, handler, base+"/outline.json", nil)365 if outline.Code != http.StatusOK || outline.Header().Get("X-Atlas-Feature-Partitions") != "0" {366 t.Fatalf("outline = %d with %q partitions", outline.Code, outline.Header().Get("X-Atlas-Feature-Partitions"))367 }368 if tile := get(t, handler, base+"/tiles/overworld/0/0/0.jpg", nil); tile.Header().Get("X-Atlas-Raster-Ranges") != "1" {369 t.Fatalf("tile read %q raster ranges, want one shard slice", tile.Header().Get("X-Atlas-Raster-Ranges"))370 }371 for _, tt := range served {372 t.Run(tt.name, func(t *testing.T) {373 got := get(t, handler, tt.path, nil)374 if got.Code != http.StatusOK {375 t.Fatalf("%s answered %d", tt.path, got.Code)376 }377 if kind := got.Header().Get("Content-Type"); kind != tt.kind {378 t.Errorf("Content-Type = %q, want %q", kind, tt.kind)379 }380 if tt.body != "" && got.Body.String() != tt.body {381 t.Errorf("body is %q, want %q", got.Body.String(), tt.body)382 }383 if got.Body.Len() == 0 {384 t.Error("native content body is empty")385 }386 if cache := got.Header().Get("Cache-Control"); cache != "private, max-age=31536000, immutable" {387 t.Errorf("Cache-Control = %q: a stamped URL names one build forever", cache)388 }389 })390 }391392 refused := []struct {393 name string394 path string395 }{396 {"a stamp that is not the serving build", "/data/v/tunic/000000000000/worlds/overworld.json"},397 {"a volume that is not installed", "/data/v/not-a-volume/" + bundle.ShortStamp(tunicStamp) + "/worlds/overworld.json"},398 {"the retired world JSON projection", base + "/worlds/overworld.json"},399 {"the retired packed-location projection", base + "/worlds/overworld.bin"},400 {"the retired deferred-text projection", base + "/worlds/overworld.text"},401 {"the retired icon namespace", base + "/icons/marker.svg"},402 {"an entry the native bundle does not hold", base + "/atlas.json"},403 {"a path under the shell that is not a page", "/not-a-page"},404 }405 for _, tt := range refused {406 t.Run(tt.name, func(t *testing.T) {407 got := get(t, handler, tt.path, nil)408 if got.Code != http.StatusNotFound {409 t.Errorf("%s answered %d, want 404", tt.path, got.Code)410 }411 })412 }413}414415type extremeVolume struct{ *fakeVolume }416417func (v *extremeVolume) Outline() vnext.Volume {418 outline := v.fakeVolume.Outline()419 field := func(name string) vnext.Field {420 return vnext.Field{ID: vnext.IDFromName("example.test", name), Name: name, Kind: vnext.KindInt64}421 }422 maximum, minimum := field("sample.maximum"), field("sample.minimum")423 outline.Worlds[0].Claims = []vnext.Property{424 {FieldID: maximum.ID, Field: maximum, Value: vnext.Int64Value(int64(^uint64(0) >> 1))},425 {FieldID: minimum.ID, Field: minimum, Value: vnext.Int64Value(-int64(^uint64(0)>>1) - 1)},426 }427 return outline428}429430func (v *extremeVolume) FeaturePage(vnext.FeaturePageRequest) (vnext.FeaturePageResult, error) {431 field := vnext.Field{ID: vnext.IDFromName("example.test", "extreme"), Name: "sample.extreme", Kind: vnext.KindInt64}432 feature := func(id string, value int64) vnext.Feature {433 return vnext.Feature{434 ID: id, Title: id,435 Geometry: vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{0, 0}}}}}},436 Properties: []vnext.Property{{FieldID: field.ID, Field: field, Value: vnext.Int64Value(value)}},437 }438 }439 return vnext.FeaturePageResult{Features: []vnext.Feature{440 feature("maximum", int64(^uint64(0)>>1)),441 feature("minimum", -int64(^uint64(0)>>1)-1),442 }}, nil443}444445func TestFeaturePageWirePreservesInt64Extremes(t *testing.T) {446 handler, _ := newApp(t, &extremeVolume{fakeVolume: volume("sample", "Sample", tunicStamp)})447 base := "/data/v/sample/" + bundle.ShortStamp(tunicStamp)448 got := get(t, handler, base+"/features/extreme.json", nil)449 if got.Code != http.StatusOK {450 t.Fatalf("feature page answered %d: %s", got.Code, got.Body.String())451 }452 var page struct {453 Features []struct {454 Properties []struct {455 Value string `json:"value"`456 } `json:"properties"`457 } `json:"features"`458 }459 if err := json.Unmarshal(got.Body.Bytes(), &page); err != nil {460 t.Fatal(err)461 }462 values := []string{page.Features[0].Properties[0].Value, page.Features[1].Properties[0].Value}463 if values[0] != "9223372036854775807" || values[1] != "-9223372036854775808" {464 t.Fatalf("int64 wire values = %v", values)465 }466467 outline := get(t, handler, base+"/outline.json", nil)468 var pageZero struct {469 Version int `json:"version"`470 Worlds []struct {471 Claims []struct {472 Value string `json:"value"`473 } `json:"claims"`474 } `json:"worlds"`475 }476 if err := json.Unmarshal(outline.Body.Bytes(), &pageZero); err != nil {477 t.Fatal(err)478 }479 claims := pageZero.Worlds[0].Claims480 if pageZero.Version != 1 || claims[0].Value != values[0] || claims[1].Value != values[1] {481 t.Fatalf("outline wire = version %d, claims %#v", pageZero.Version, claims)482 }483}484485// A Range request is answered whole: 200, the full body, no Accept-Ranges.486// Tiles are stored uncompressed so that ranges *could* be served, and data.go487// says why they deliberately are not; this test is what goes red if somebody488// implements them without deciding to.489func TestContentDoesNotServeRanges(t *testing.T) {490 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))491 path := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp) + "/tiles/overworld/0/0/0.jpg"492493 got := get(t, handler, path, map[string]string{"Range": "bytes=0-2"})494 if got.Code != http.StatusOK {495 t.Fatalf("a range request answered %d, want 200 with the whole body", got.Code)496 }497 if got.Body.String() != "raster" {498 t.Errorf("body = %q, want the whole entry", got.Body.String())499 }500 if accept := got.Header().Get("Accept-Ranges"); accept != "" {501 t.Errorf("Accept-Ranges = %q, want the plane to make no such offer", accept)502 }503}504505func TestHomeSendsAReaderBackWhereTheyWere(t *testing.T) {506 handler, host := newApp(t,507 volume("tunic", "TUNIC", tunicStamp),508 volume("mars", "Mars", marsStamp))509510 // Nobody has been anywhere yet: the first volume in catalog order stands511 // in for "where you were".512 got := get(t, handler, "/", nil)513 if got.Code != http.StatusFound {514 t.Fatalf("/ answered %d, want a redirect to a world", got.Code)515 }516 if where := got.Header().Get("Location"); where != "/v/mars/overworld" {517 t.Errorf("/ sent the reader to %q", where)518 }519520 // Visiting a world remembers it, and / follows.521 if page := get(t, handler, "/v/tunic/overworld", nil); page.Code != http.StatusOK {522 t.Fatalf("the explorer answered %d", page.Code)523 }524 got = get(t, handler, "/", nil)525 if where := got.Header().Get("Location"); where != "/v/tunic/overworld" {526 t.Errorf("after visiting tunic, / sent the reader to %q", where)527 }528 if _, err := host.sessions.Load("volume.tunic.json"); err != nil {529 t.Errorf("visiting a world wrote no session record: %v", err)530 }531}532533func TestHomeWithNothingInstalled(t *testing.T) {534 handler, _ := newApp(t)535 got := get(t, handler, "/", nil)536 if got.Code != http.StatusOK {537 t.Fatalf("an empty library answered %d, want the library card", got.Code)538 }539 if !strings.Contains(got.Body.String(), "/library") {540 t.Error("the empty state does not say where a bundle goes")541 }542}543544func TestExplorerRefusesWhatIsNotThere(t *testing.T) {545 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))546 for _, path := range []string{"/v/not-a-volume/overworld", "/v/tunic/not-a-world"} {547 if got := get(t, handler, path, nil); got.Code != http.StatusNotFound {548 t.Errorf("%s answered %d, want 404", path, got.Code)549 }550 }551}552553// One route per concern, each answering with the regions it touched. The554// table is the contract: a concern that starts moving a different set of555// regions has changed the page's behavior and should say so here first.556func TestSessionConcerns(t *testing.T) {557 tests := []struct {558 concern string559 form url.Values560 targets []string561 check func(t *testing.T, s app.Session)562 }{563 {564 concern: "world",565 form: url.Values{"world": {"overworld"}},566 targets: []string{"#atlas-topbar", "#atlas-legend", "#atlas-dock", "#atlas-overview", "#atlas-viewport-state"},567 check: func(t *testing.T, s app.Session) {568 if s.World != "overworld" {569 t.Errorf("world = %q", s.World)570 }571 },572 },573 {574 concern: "lens",575 form: url.Values{"lens": {"satellite"}},576 // The legend and the dock are in the set because a lens can be a577 // different *layer* of a split sheet, and the ground under the578 // reader changes with it: which shapes the index lists and which579 // features the panel can name are the new layer's answers.580 targets: []string{581 "#atlas-topbar", "#atlas-legend", "#atlas-dock", "#atlas-overview",582 "#atlas-viewport-state",583 },584 check: func(t *testing.T, s app.Session) {585 if s.Lens != "satellite" {586 t.Errorf("lens = %q", s.Lens)587 }588 },589 },590 {591 concern: "collections",592 form: url.Values{"collection": {"7"}, "visible": {"0"}},593 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},594 check: func(t *testing.T, s app.Session) {595 if len(s.Hidden) != 1 || s.Hidden[0] != "7" {596 t.Errorf("hidden = %v", s.Hidden)597 }598 },599 },600 {601 concern: "sections",602 form: url.Values{"section": {"quests"}, "open": {"0"}},603 targets: []string{"#atlas-legend"},604 check: func(t *testing.T, s app.Session) {605 // A fresh record is arranged before the concern runs, and606 // the arrangement folds the viewer's own Zones section, so607 // what this asserts is that the named section joined it.608 if !contains(s.Collapsed, "quests") {609 t.Errorf("collapsed = %v", s.Collapsed)610 }611 },612 },613 {614 concern: "labels",615 form: url.Values{"collection": {"7"}, "policy": {"always"}},616 targets: []string{"#atlas-legend", "#atlas-viewport-state"},617 check: func(t *testing.T, s app.Session) {618 if s.Labels["7"] != "always" {619 t.Errorf("labels = %v", s.Labels)620 }621 },622 },623 {624 // Isolating is a move on the hide set rather than a state of625 // its own, and the chip is derived from what is hidden. With626 // nothing named, the chip's own way out -- show everything --627 // is what the route means.628 concern: "solo",629 form: url.Values{},630 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},631 check: func(t *testing.T, s app.Session) {632 if len(s.Hidden) != 0 {633 t.Errorf("hidden = %v", s.Hidden)634 }635 },636 },637 {638 concern: "expand",639 form: url.Values{"collection": {"7"}, "open": {"1"}},640 targets: []string{"#atlas-legend"},641 check: func(t *testing.T, s app.Session) {642 if len(s.Expanded) != 1 || s.Expanded[0] != "7" {643 t.Errorf("expanded = %v", s.Expanded)644 }645 },646 },647 {648 concern: "highlight",649 form: url.Values{"feature": {"31"}},650 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},651 check: func(t *testing.T, s app.Session) {652 if len(s.Highlighted) != 1 || s.Highlighted[0] != "31" {653 t.Errorf("highlighted = %v", s.Highlighted)654 }655 },656 },657 {658 concern: "overview",659 form: url.Values{"docked": {"1"}},660 targets: []string{"#atlas-overview"},661 check: func(t *testing.T, s app.Session) {662 if !s.Overview.Docked {663 t.Error("the overview did not dock")664 }665 },666 },667 {668 concern: "search",669 form: url.Values{"q": {"shrine"}},670 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},671 check: func(t *testing.T, s app.Session) {672 if s.Search != "shrine" {673 t.Errorf("search = %q", s.Search)674 }675 },676 },677 {678 concern: "dock",679 form: url.Values{"open": {"1"}, "section": {"counts"}},680 targets: []string{"#atlas-dock"},681 check: func(t *testing.T, s app.Session) {682 if !s.Dock.Open || s.Dock.Section != "counts" {683 t.Errorf("dock = %+v", s.Dock)684 }685 },686 },687 {688 concern: "select",689 form: url.Values{"feature": {"1849"}},690 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-detail", "#atlas-viewport-state"},691 check: func(t *testing.T, s app.Session) {692 if s.Selected != "1849" || !s.Detail.Open {693 t.Errorf("selection = %q, detail = %+v", s.Selected, s.Detail)694 }695 },696 },697 {698 concern: "grid",699 // The field posts what the reader typed and the record holds an700 // address: four characters is past the geohash telescope's floor,701 // so what lands is the three-character cell the navigator would702 // have shown (internal/app/grid.go, normalizeCell).703 form: url.Values{"system": {"geohash"}, "cell": {"9q5c"}, "subgrid": {"2"}},704 targets: []string{"#atlas-grid-navigator", "#atlas-dock", "#atlas-viewport-state"},705 check: func(t *testing.T, s app.Session) {706 if s.Grid.System != "geohash" || s.Grid.Cell != "9q5" || s.Grid.Subgrid != 2 {707 t.Errorf("grid = %+v", s.Grid)708 }709 },710 },711 {712 concern: "sidebar",713 form: url.Values{"open": {"1"}},714 targets: []string{"#atlas-shell"},715 check: func(t *testing.T, s app.Session) {716 if s.Sidebar.Collapsed {717 t.Error("the sidebar did not open")718 }719 },720 },721 }722723 for _, tt := range tests {724 t.Run(tt.concern, func(t *testing.T) {725 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))726 form := url.Values{"volume": {"tunic"}}727 for name, values := range tt.form {728 form[name] = values729 }730 got := post(t, handler, "/session/"+tt.concern, form)731 if got.Code != http.StatusOK {732 t.Fatalf("/session/%s answered %d: %s", tt.concern, got.Code, got.Body)733 }734 if kind := got.Header().Get("Content-Type"); kind != "text/html; charset=utf-8" {735 t.Errorf("Content-Type = %q", kind)736 }737 body := got.Body.String()738 for _, target := range tt.targets {739 if !strings.Contains(body, `target="`+target+`"`) {740 t.Errorf("the answer carries no partial for %s:\n%s", target, body)741 }742 }743 // The regions the concern touches, and the island. Every answer744 // carries the island because every answer has just written the745 // record it publishes; it is inert and it moves nothing on746 // screen, so it is not one of the regions an interaction is747 // judged by, and it is not in the table above.748 if !strings.Contains(body, `target="#atlas-session-island"`) {749 t.Errorf("the answer carries no state island:\n%s", body)750 }751 if swaps := strings.Count(body, "<hx-partial"); swaps != len(tt.targets)+1 {752 t.Errorf("the answer carries %d partials, want the %d regions the concern touches and the island",753 swaps, len(tt.targets))754 }755756 held, err := host.sessions.Load("volume.tunic.json")757 if err != nil {758 t.Fatalf("no session was written: %v", err)759 }760 var session app.Session761 if err := json.Unmarshal(held, &session); err != nil {762 t.Fatal(err)763 }764 if session.Schema != app.SessionSchema {765 t.Errorf("record schema = %d", session.Schema)766 }767 tt.check(t, session)768 })769 }770}771772// The blunt reset, which is โŒ˜R's whole job: this volume's record is deleted773// and the reader is put back into the volume with nothing remembered about it.774//775// It exists because a record that has gone wrong is not a thing anybody wants776// to diagnose one field at a time -- highlights culled at every pin, a filter777// with no row to press -- and the state a volume opens with is synthesized778// from the world itself, so throwing the record away *is* the way back.779//780// Two things this test holds that are easy to lose. The answer is a full round781// trip rather than a partial set, because after a reset there is no record for782// a region to be rendered from; and the last-volume pointer survives, because783// the reader is not leaving the volume, they are standing in it with a clean784// record.785func TestSessionResetForgetsTheVolumeAndComesBack(t *testing.T) {786 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))787788 // A reader who has been somewhere: the page opened, a collection put away,789 // a feature highlighted, a search typed, a grid opened and a camera790 // settled. Every one of these is a field the reset has to take with it.791 if page := get(t, handler, "/v/tunic/overworld", nil); page.Code != http.StatusOK {792 t.Fatalf("the explorer answered %d", page.Code)793 }794 for _, step := range []struct {795 concern string796 form url.Values797 }{798 {"collections", url.Values{"collection": {"7"}, "visible": {"0"}}},799 {"highlight", url.Values{"feature": {"1849"}}},800 {"search", url.Values{"q": {"mill"}}},801 {"grid", url.Values{"system": {"toggle"}}},802 {"view", url.Values{"world": {"overworld"}, "x": {"120.5"}, "y": {"-40"}, "zoom": {"6.25"}}},803 } {804 form := url.Values{"volume": {"tunic"}}805 for name, values := range step.form {806 form[name] = values807 }808 if got := post(t, handler, "/session/"+step.concern, form); got.Code != http.StatusOK {809 t.Fatalf("/session/%s answered %d: %s", step.concern, got.Code, got.Body)810 }811 }812 before := sessionRecord(t, host, "volume.tunic.json")813 if len(before.Hidden) == 0 || len(before.Highlighted) == 0 || before.Search == "" ||814 before.Grid.System == "" || len(before.Cameras) == 0 {815 t.Fatalf("the record under test was never arranged: %+v", before)816 }817818 got := post(t, handler, "/session/reset", url.Values{"volume": {"tunic"}})819 if got.Code != http.StatusNoContent {820 t.Fatalf("the reset answered %d: %s", got.Code, got.Body)821 }822 // The full refresh, as the header htmx acts on itself. A partial set would823 // be rendered out of a record that no longer exists, and a 303 would be824 // followed by fetch and swapped into a page that asked for no swap.825 if back := got.Header().Get("HX-Redirect"); back != "/v/tunic/overworld" {826 t.Errorf("the reset answers HX-Redirect %q, want the volume's own world back", back)827 }828 if got.Body.Len() != 0 {829 t.Errorf("the reset answers with a body:\n%s", got.Body)830 }831 if strings.Contains(got.Body.String(), "<hx-partial") {832 t.Errorf("the reset answers with partials over a record it just deleted:\n%s", got.Body)833 }834835 // The record is gone from the store, which is the whole of the reset.836 if _, err := host.sessions.Load("volume.tunic.json"); !errors.Is(err, hostenv.ErrNoSession) {837 t.Errorf("the volume's record survived the reset: %v", err)838 }839 // And the pointer did not go with it: the reader is still in this volume.840 if _, err := host.sessions.Load("app.json"); err != nil {841 t.Errorf("the reset forgot which volume the reader is in: %v", err)842 }843844 // What the redirect lands on: a page synthesized out of the world's own845 // arrangement. The island is the server's account of it, and it says the846 // three things the reader lost their hour to -- nothing hidden, nothing847 // highlighted, and a camera nobody has reported, which is the chart848 // fitting the world again.849 page := get(t, handler, "/v/tunic/overworld", nil)850 if page.Code != http.StatusOK {851 t.Fatalf("the page the reset comes back to answered %d", page.Code)852 }853 for _, want := range []string{`"hidden":[]`, `"center":null`, `"zoom":null`, `"dockFolded":true`} {854 if !strings.Contains(page.Body.String(), want) {855 t.Errorf("the island after a reset does not say %s:\n%s", want, page.Body)856 }857 }858 after := sessionRecord(t, host, "volume.tunic.json")859 if len(after.Hidden) != 0 || len(after.Highlighted) != 0 || after.Search != "" ||860 after.Grid.System != "" || after.Grid.Cell != "" || len(after.Cameras) != 0 ||861 after.Selected != "" || after.Detail.Open || after.Dock.Open || after.Overview.Docked ||862 after.Sidebar.Collapsed || len(after.Labels) != 0 {863 t.Errorf("the record the reset came back to is not fresh: %+v", after)864 }865 // The subdivision is the one field a fresh record does not leave at its866 // zero value: a grid a reader opens is a grid with its next level drawn.867 if after.Grid.Subgrid != 1 {868 t.Errorf("the fresh record's subgrid = %d, want the arrangement's own 1", after.Grid.Subgrid)869 }870}871872// A reset with nothing to reset. Pressing โŒ˜R twice, or on a volume opened and873// never arranged, is the same request against a store that holds no record for874// it -- and deleting what is not there is what the caller asked for, so it875// answers exactly as the first press did and writes nothing behind it.876func TestSessionResetWithNoRecordIsANoOp(t *testing.T) {877 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))878879 got := post(t, handler, "/session/reset", url.Values{"volume": {"tunic"}})880 if got.Code != http.StatusNoContent {881 t.Fatalf("a reset with nothing to reset answered %d: %s", got.Code, got.Body)882 }883 // The world came from the manifest rather than from the record, because884 // there was no record to read it out of.885 if back := got.Header().Get("HX-Redirect"); back != "/v/tunic/overworld" {886 t.Errorf("HX-Redirect = %q, want the volume's first world", back)887 }888 names, err := host.sessions.Names()889 if err != nil {890 t.Fatal(err)891 }892 for _, name := range names {893 if name == "volume.tunic.json" {894 t.Errorf("a reset wrote the record it was asked to delete: %v", names)895 }896 }897}898899// sessionRecord reads one volume's record out of the store under test.900func sessionRecord(t *testing.T, host *fakeHost, name string) app.Session {901 t.Helper()902 held, err := host.sessions.Load(name)903 if err != nil {904 t.Fatalf("no session record %s: %v", name, err)905 }906 var session app.Session907 if err := json.Unmarshal(held, &session); err != nil {908 t.Fatal(err)909 }910 return session911}912913// The camera is the one continuous thing the server keeps, and it answers with914// nothing a reader can see: a swap of any of the chrome in response to a915// settling camera would fight the reader's own hand. What it does answer with916// is the state island, which is an inert script node -- no focus to lose, no917// scroll to reset -- because otherwise the camera it just wrote would be918// readable only after the next unrelated request, and the island's whole919// point is that the two accounts of the arrangement diff now, not later.920func TestCameraReportIsQuiet(t *testing.T) {921 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))922 got := post(t, handler, "/session/view", url.Values{923 "volume": {"tunic"}, "world": {"overworld"},924 "x": {"120.5"}, "y": {"-40"}, "zoom": {"6.25"}, "rotation": {"0.5"},925 })926 if got.Code != http.StatusOK {927 t.Fatalf("a camera report answered %d, want 200", got.Code)928 }929 body := got.Body.String()930 if strings.Count(body, "<hx-partial") != 1 ||931 !strings.Contains(body, `target="#atlas-session-island"`) {932 t.Errorf("a camera report answered with more than the island:\n%s", body)933 }934 if !strings.Contains(body, `"zoom":6.25`) {935 t.Errorf("the island does not carry the camera just reported:\n%s", body)936 }937938 held, err := host.sessions.Load("volume.tunic.json")939 if err != nil {940 t.Fatal(err)941 }942 var session app.Session943 if err := json.Unmarshal(held, &session); err != nil {944 t.Fatal(err)945 }946 camera, kept := session.Cameras["overworld"]947 if !kept || camera.X != 120.5 || camera.Zoom != 6.25 {948 t.Errorf("camera = %+v, kept = %v", camera, kept)949 }950 if camera.At == "" {951 t.Error("the camera report carries no time")952 }953}954955// A pick off the canvas, end to end: the page's half and the handler's.956//957// The seam resolves a hit and can do nothing with it on its own -- it fills a958// form this page renders and says so, exactly as the camera does. So the959// first half of this is that the form is there and wired, which is the whole960// of the defect it was written for: both panes reported picks for a milestone961// and nothing was listening, so clicking a feature moved nothing.962//963// The second half is the field the form deliberately does not carry. `focus`964// is what a row reached for from a list means -- take me there, and mark it965// in the index while I stand on it -- and a pick is not that: the reader who966// clicked the ground was already on it. Selecting and going are two facts,967// and only one of them is a canvas click's to state.968func TestCanvasPickSelectsWithoutFocusing(t *testing.T) {969 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))970971 page := get(t, handler, "/v/tunic/overworld", nil)972 if page.Code != http.StatusOK {973 t.Fatalf("the explorer answered %d", page.Code)974 }975 shell := page.Body.String()976 opens := strings.Index(shell, `<form hidden id="atlas-pick"`)977 if opens < 0 {978 t.Fatalf("the page renders no pick form, so a canvas pick posts nothing:\n%s", shell)979 }980 closes := strings.Index(shell[opens:], "</form>")981 if closes < 0 {982 t.Fatalf("the pick form is never closed:\n%s", shell[opens:])983 }984 pickForm := shell[opens : opens+closes]985 for _, want := range []string{986 `hx-post="/session/select"`,987 `hx-trigger="atlas:pick from:window"`,988 `name="feature" id="atlas-pick-feature"`,989 } {990 if !strings.Contains(pickForm, want) {991 t.Errorf("the pick form is missing %s:\n%s", want, pickForm)992 }993 }994 // The volume is inherited from the shell like every other interaction's,995 // and the focus flag is not here at all.996 if strings.Contains(pickForm, "focus") {997 t.Errorf("the pick form posts a focus field, so a pick would jump:\n%s", pickForm)998 }9991000 got := post(t, handler, "/session/select", url.Values{1001 "volume": {"tunic"}, "feature": {"1849"},1002 })1003 if got.Code != http.StatusOK {1004 t.Fatalf("a canvas pick answered %d: %s", got.Code, got.Body)1005 }1006 if body := got.Body.String(); !strings.Contains(body, `target="#atlas-detail"`) {1007 t.Errorf("a pick answered without the card it opened:\n%s", body)1008 }10091010 held, err := host.sessions.Load("volume.tunic.json")1011 if err != nil {1012 t.Fatalf("a pick wrote no session record: %v", err)1013 }1014 var session app.Session1015 if err := json.Unmarshal(held, &session); err != nil {1016 t.Fatal(err)1017 }1018 if session.Selected != "1849" || !session.Detail.Open {1019 t.Errorf("selection = %q, detail = %+v", session.Selected, session.Detail)1020 }1021 if session.Focused != "" {1022 t.Errorf("a canvas pick moved the index mark to %q; only a list row does that",1023 session.Focused)1024 }1025}10261027// A cell chosen off a surface, end to end: the page's half and the handler's.1028//1029// It is the pick's arrangement exactly, for the other thing a surface can be1030// pointed at. A reader telescopes three ways -- typing an address, clicking a1031// cell the chart drew, pressing a point on the sphere -- and only the first1032// has a control of its own. The other two are pixels, and a pixel is the1033// seam's to resolve; what it resolves to has to land somewhere, and this form1034// is where.1035//1036// The field it does NOT carry is the interesting half. Which system is1037// dividing the map is already in the record -- the address was resolved by1038// that system -- so a seam posting one alongside would be the seam holding an1039// opinion about state it does not own, and a stale one at that.1040func TestGridPickFormCarriesTheAddressAndNothingElse(t *testing.T) {1041 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))10421043 page := get(t, handler, "/v/tunic/overworld", nil)1044 if page.Code != http.StatusOK {1045 t.Fatalf("the explorer answered %d", page.Code)1046 }1047 shell := page.Body.String()1048 opens := strings.Index(shell, `<form hidden id="atlas-grid-pick"`)1049 if opens < 0 {1050 t.Fatalf("the page renders no grid-pick form, so a cell clicked posts nothing:\n%s", shell)1051 }1052 closes := strings.Index(shell[opens:], "</form>")1053 if closes < 0 {1054 t.Fatalf("the grid-pick form is never closed:\n%s", shell[opens:])1055 }1056 pickForm := shell[opens : opens+closes]1057 for _, want := range []string{1058 `hx-post="/session/grid"`,1059 `hx-trigger="atlas:grid-pick from:window"`,1060 `name="cell" id="atlas-grid-pick-cell"`,1061 } {1062 if !strings.Contains(pickForm, want) {1063 t.Errorf("the grid-pick form is missing %s:\n%s", want, pickForm)1064 }1065 }1066 // The address is the one field. The volume is inherited from the shell,1067 // like every other interaction's on this page.1068 if strings.Contains(pickForm, `name="system"`) {1069 t.Errorf("the grid-pick form posts a system, which is already in the record:\n%s", pickForm)1070 }1071 if strings.Contains(pickForm, `name="volume"`) {1072 t.Errorf("the grid-pick form posts its own volume rather than inheriting one:\n%s", pickForm)1073 }1074 // It posts to the route the navigator's own field posts to, with the same1075 // field name, so one address is normalized and validated for all three1076 // ways into a cell.1077 if !strings.Contains(shell, `id="grid-input" name="cell"`) {1078 t.Errorf("the navigator's field and the grid-pick form no longer name one field:\n%s", shell)1079 }1080 // And it posts every keystroke, with no memory of the last one. `changed`1081 // remembers the value an event carried and refuses one that matches, which1082 // is right for a field nobody else writes to and wrong for this one: every1083 // swap re-renders it, so ascending out of "m" leaves an empty field beside1084 // a remembered "m", and a reader typing that hash again is answered with1085 // silence. `input` already fires only when the value moved.1086 if !strings.Contains(shell, `hx-post="/session/grid" hx-trigger="input delay:150ms"`) {1087 t.Errorf("the navigator's field does not post an ordinary debounced input:\n%s", shell)1088 }10891090 got := post(t, handler, "/session/grid", url.Values{1091 "volume": {"tunic"}, "cell": {"9q"},1092 })1093 if got.Code != http.StatusOK {1094 t.Fatalf("a cell chosen off a surface answered %d: %s", got.Code, got.Body)1095 }10961097 held, err := host.sessions.Load("volume.tunic.json")1098 if err != nil {1099 t.Fatalf("a cell wrote no session record: %v", err)1100 }1101 var session app.Session1102 if err := json.Unmarshal(held, &session); err != nil {1103 t.Fatal(err)1104 }1105 if session.Grid.Cell != "9q" {1106 t.Errorf("cell = %q, want the one the surface named", session.Grid.Cell)1107 }1108}11091110func TestSessionRefusals(t *testing.T) {1111 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1112 cases := []struct {1113 name string1114 path string1115 form url.Values1116 want int1117 }{1118 {"a concern nobody declared", "/session/telepathy", url.Values{"volume": {"tunic"}}, http.StatusNotFound},1119 {"no volume named", "/session/dock", url.Values{}, http.StatusBadRequest},1120 {"a volume that is not installed", "/session/dock", url.Values{"volume": {"mars"}}, http.StatusNotFound},1121 {"a world that is not a slug", "/session/world", url.Values{"volume": {"tunic"}, "world": {"../etc"}}, http.StatusBadRequest},1122 {"a camera report with no camera", "/session/view", url.Values{"volume": {"tunic"}, "world": {"overworld"}}, http.StatusBadRequest},1123 }1124 for _, tt := range cases {1125 t.Run(tt.name, func(t *testing.T) {1126 if got := post(t, handler, tt.path, tt.form); got.Code != tt.want {1127 t.Errorf("%s answered %d, want %d", tt.path, got.Code, tt.want)1128 }1129 })1130 }1131}11321133func TestEarthOffersAProgressivelyEnhancedMintFlow(t *testing.T) {1134 earth := volume("earth", "Earth", tunicStamp)1135 earth.entries["worlds/overworld.json"] = []byte(`{"attrs":{"atlas.geometry.surface":"sphere","atlas.geometry.projection":"web-mercator"},"lenses":[],"collections":[]}`)1136 host := &fakeHost{1137 volumes: &fakeVolumes{volumes: []hostenv.Volume{earth}, location: "/library"},1138 sessions: hostenv.NewMemorySessions(),1139 }1140 minter := &fakeMinter{1141 request: minting.Request{1142 Title: "My Atlas", Bounds: [4]float64{-105.1, 39.6, -104.9, 39.8}, DetailZoom: 12,1143 Topo: true, Roads: true, Hydro: true, Counties: true,1144 },1145 preview: minting.Preview{Pixels: 2048, RasterTiles: 85, Requests: 7, EstimatedBytes: 54 << 20},1146 }1147 handler := app.New(host, app.Options{Minter: minter})11481149 page := get(t, handler, "/v/earth/overworld", nil)1150 if page.Code != http.StatusOK {1151 t.Fatalf("Earth answered %d: %s", page.Code, page.Body)1152 }1153 body := page.Body.String()1154 for _, want := range []string{1155 `id="atlas-create"`, `href="#atlas-mint"`, `id="atlas-mint"`,1156 `action="/mint"`, `name="west"`, `name="south"`, `name="east"`, `name="north"`,1157 `name="topo"`, `name="roads"`, `name="hydro"`, `name="counties"`,1158 `data-mint-current-view`, `data-mint-draw`, `Mint, install &amp; open`,1159 `change delay:250ms from:#atlas-mint-form`, `atlas:mint-area from:window`,1160 `hx-config='{"timeout":600000}'`, `hx-sync="this:drop"`,1161 } {1162 if !strings.Contains(body, want) {1163 t.Errorf("embedded mint flow is missing %s:\n%s", want, body)1164 }1165 }11661167 plain, _ := newApp(t, volume("tunic", "TUNIC", marsStamp))1168 if body := get(t, plain, "/v/tunic/overworld", nil).Body.String(); strings.Contains(body, `id="atlas-create"`) || strings.Contains(body, `id="atlas-mint"`) {1169 t.Errorf("a plane world offers Earth minting:\n%s", body)1170 }1171}11721173func TestMintPreviewAndPublishStayInsideAtlas(t *testing.T) {1174 earth := volume("earth", "Earth", tunicStamp)1175 earth.entries["worlds/overworld.json"] = []byte(`{"attrs":{"atlas.geometry.surface":"sphere"},"lenses":[],"collections":[]}`)1176 minted := volume("my-region", "My Region", marsStamp, bundle.WorldEntry{Slug: "my-region", Title: "My Region", UpdatedAt: "2026-01-01T00:00:00Z"})1177 minted.entries["worlds/my-region.json"] = []byte(`{"lenses":[],"collections":[]}`)1178 minted.entries["worlds/my-region.bin"] = bundle.PackLocations(nil)1179 minted.entries["worlds/my-region.text"] = []byte(`{}`)1180 host := &fakeHost{1181 volumes: &fakeVolumes{volumes: []hostenv.Volume{earth}, location: "/library"},1182 sessions: hostenv.NewMemorySessions(),1183 }1184 minter := &fakeMinter{1185 preview: minting.Preview{Pixels: 1024, RasterTiles: 21, Requests: 7, EstimatedBytes: 12 << 20},1186 result: minting.Result{Slug: "my-region", World: "my-region", Title: "My Region", Stamp: marsStamp},1187 }1188 minter.onMint = func() { host.volumes.volumes = append(host.volumes.volumes, minted) }1189 handler := app.New(host, app.Options{Minter: minter})1190 form := url.Values{1191 "title": {"My Region"}, "west": {"-105.1"}, "south": {"39.6"},1192 "east": {"-104.9"}, "north": {"39.8"}, "detail": {"12"},1193 "topo": {"true"}, "roads": {"true"}, "hydro": {"true"}, "counties": {"true"},1194 }11951196 preview := post(t, handler, "/mint/preview", form)1197 if preview.Code != http.StatusOK {1198 t.Fatalf("preview answered %d: %s", preview.Code, preview.Body)1199 }1200 for _, want := range []string{"1024 px", "21 raster tiles", "7 source groups", "12 MiB"} {1201 if !strings.Contains(preview.Body.String(), want) {1202 t.Errorf("preview misses %q:\n%s", want, preview.Body)1203 }1204 }12051206 request := httptest.NewRequest(http.MethodPost, "/mint", strings.NewReader(form.Encode()))1207 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")1208 request.Header.Set("HX-Request", "true")1209 response := httptest.NewRecorder()1210 handler.ServeHTTP(response, request)1211 if response.Code != http.StatusNoContent {1212 t.Fatalf("mint answered %d: %s", response.Code, response.Body)1213 }1214 if got := response.Header().Get("HX-Redirect"); got != "/v/my-region/my-region" {1215 t.Errorf("mint redirect = %q", got)1216 }1217 if minter.request.Title != "My Region" || minter.request.Bounds != [4]float64{-105.1, 39.6, -104.9, 39.8} {1218 t.Errorf("mint request = %+v", minter.request)1219 }1220 if page := get(t, handler, "/v/my-region/my-region", nil); page.Code != http.StatusOK {1221 t.Errorf("minted volume did not open: %d %s", page.Code, page.Body)1222 }1223}12241225func TestMintRoutesRefuseUnavailableOrInvalidRequests(t *testing.T) {1226 handler, _ := newApp(t, volume("earth", "Earth", tunicStamp))1227 if got := post(t, handler, "/mint", url.Values{}); got.Code != http.StatusNotFound {1228 t.Fatalf("host without a minter answered %d", got.Code)1229 }12301231 host := &fakeHost{volumes: &fakeVolumes{location: "/library"}, sessions: hostenv.NewMemorySessions()}1232 minter := &fakeMinter{err: errors.New("selection is outside the supported area")}1233 handler = app.New(host, app.Options{Minter: minter})1234 form := url.Values{1235 "title": {"My Region"}, "west": {"2"}, "south": {"48"},1236 "east": {"3"}, "north": {"49"}, "detail": {"12"}, "topo": {"true"},1237 }1238 if got := post(t, handler, "/mint/preview", form); got.Code != http.StatusUnprocessableEntity || !strings.Contains(got.Body.String(), "outside the supported area") {1239 t.Fatalf("invalid preview = %d %s", got.Code, got.Body)1240 }1241}12421243// The keyboard the shell declares, read back out of the page.1244//1245// These are string assertions on markup, which is usually a smell, and here it1246// is the whole point: an hx-trigger filter is executable code the server only1247// ever writes and never runs, so the one place a missing guard can be caught1248// before a reader finds it is the bytes. Each shortcut is checked for the1249// three things every one of them has to say -- whose keystroke it is, that the1250// key is answered, and that a held key is not a hundred keys -- and for the1251// two exceptions that are deliberate.1252func TestKeyboardShortcutsAreHardened(t *testing.T) {1253 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1254 page := get(t, handler, "/v/tunic/overworld", nil)1255 if page.Code != http.StatusOK {1256 t.Fatalf("the explorer answered %d", page.Code)1257 }1258 shell := page.Body.String()1259 opens := strings.Index(shell, `<div hidden id="atlas-shortcuts">`)1260 if opens < 0 {1261 t.Fatalf("the page declares no shortcuts:\n%s", shell)1262 }1263 closes := strings.Index(shell[opens:], "</div>")1264 if closes < 0 {1265 t.Fatalf("the shortcuts block is never closed:\n%s", shell[opens:])1266 }1267 block := shell[opens : opens+closes]12681269 // The editable-target guard, spelled the one way every filter spells it. A1270 // select is in the list because a select answers keys itself, and the1271 // `instanceof Element` is what lets a key dispatched straight at the1272 // window -- a headless driver's way of pressing one -- through as nobody's1273 // typing.1274 const guard = `!(event.target instanceof Element&&` +1275 `(/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)||event.target.isContentEditable))`1276 if !strings.Contains(block, guard) {1277 t.Fatalf("no filter carries the editable-target guard:\n%s", block)1278 }12791280 for _, want := range []struct {1281 name string1282 filter string1283 guard bool1284 }{1285 // โŒ˜G is one of the two shortcuts deliberately above the guard:1286 // cycling the cell system is most wanted from inside the token field.1287 {"โŒ˜G cycles the cell system",1288 `keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='g'&&!event.repeat] from:window prevent`, false},1289 // โŒ˜R is the other, and for the reference's own reason: a reader whose1290 // page is misbehaving reaches for reload from wherever the cursor is,1291 // and a reload that answers everywhere except the field they happen to1292 // be typing in is a reload half taken away.1293 {"โŒ˜R resets this volume's session",1294 `keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='r'&&!event.repeat] from:window prevent`, false},1295 // โŒ˜โŒฅB by physical key, because Option rewrites event.key on a Mac. Two1296 // spans, each asking the fold button which way it is pointing, so1297 // neither can go stale while the shell is not re-rendered with it.1298 {"โŒ˜โŒฅB unfolds the panel",1299 `keydown[event.code=='KeyB'&&(metaKey||ctrlKey)&&altKey&&!event.repeat&&` + guard +1300 `&&document.querySelector('#dock-fold')?.getAttribute('aria-expanded')=='false'] from:window prevent`, true},1301 {"โŒ˜โŒฅB folds the panel",1302 `keydown[event.code=='KeyB'&&(metaKey||ctrlKey)&&altKey&&!event.repeat&&` + guard +1303 `&&document.querySelector('#dock-fold')?.getAttribute('aria-expanded')=='true'] from:window prevent`, true},1304 // โŒ˜B refuses altKey, so the two B chords can never both answer.1305 {"โŒ˜B puts the index away",1306 `keydown[(metaKey||ctrlKey)&&!altKey&&key.toLowerCase()=='b'&&!event.repeat&&` + guard +1307 `] from:window prevent`, true},1308 {"G opens the grid",1309 `keydown[key.toLowerCase()=='g'&&!metaKey&&!ctrlKey&&!altKey&&!event.repeat&&` + guard +1310 `] from:window prevent`, true},1311 {"Space divides the chosen cell",1312 `keydown[key==' '&&!event.repeat&&` + guard +1313 `&&document.querySelector('#atlas-grid-navigator')?.hidden===false` +1314 `&&event.target!==document.querySelector('#subgrid-toggle')] from:window prevent`, true},1315 // The field's own space bar, which the guard above shuts out by1316 // design: it is heard on the field, so it needs no guard of its own.1317 {"Space divides it from the field too",1318 `keydown[key==' '&&!event.repeat] from:#grid-input prevent`, false},1319 {"Escape closes the card",1320 `keydown[key=='Escape'&&!event.repeat&&` + guard + `] from:#map prevent`, true},1321 {"Escape telescopes the grid out",1322 `keydown[key=='Escape'&&!event.repeat&&` + guard +1323 `&&document.querySelector('#atlas-grid-navigator')?.hidden===false] from:window prevent`, true},1324 } {1325 if !strings.Contains(block, `hx-trigger="`+want.filter+`"`) {1326 t.Errorf("%s is missing or unhardened; wanted the trigger\n %s\nin\n%s",1327 want.name, want.filter, block)1328 continue1329 }1330 if want.guard && !strings.Contains(want.filter, guard) {1331 t.Errorf("%s does not carry the editable-target guard", want.name)1332 }1333 }13341335 // The routes the shortcuts stand on, and the fold's two directions.1336 for _, want := range []string{1337 `hx-post="/session/grid" hx-vals:append='{"system":"cycle"}'`,1338 `hx-post="/session/grid" hx-vals:append='{"system":"toggle"}'`,1339 `hx-post="/session/grid" hx-vals:append='{"subgrid":"flip"}'`,1340 `hx-post="/session/grid" hx-vals:append='{"ascend":"1"}'`,1341 `hx-post="/session/dock" hx-vals:append='{"open":"1","byHand":"1"}'`,1342 `hx-post="/session/dock" hx-vals:append='{"open":"0","byHand":"1"}'`,1343 `hx-post="/session/select" hx-vals:append='{"feature":""}'`,1344 `hx-post="/session/sidebar"`,1345 `hx-post="/session/reset"`,1346 } {1347 if !strings.Contains(block, want) {1348 t.Errorf("the shortcuts block posts no %s:\n%s", want, block)1349 }1350 }13511352 // Escape means two things at once and both happen, so the two requests it1353 // raises are queued rather than raced: the card closes, and then the grid1354 // ascends against a record the first answer has already written.1355 if got := strings.Count(block, `hx-sync="#atlas-shell:queue all"`); got != 2 {1356 t.Errorf("%d of Escape's two routes queue on the shell; a press would race itself:\n%s",1357 got, block)1358 }13591360 // โŒ˜R takes the browser's reload and hands it back. The keystroke the page1361 // swallows is answered with the volume's own address again, over a record1362 // that has just been deleted -- so the shortcut has to stand above the1363 // editable guard, where the reference put it, and the span that claims it1364 // must carry no guard at all.1365 reset := strings.Index(block, `hx-post="/session/reset"`)1366 if reset < 0 {1367 t.Fatalf("nothing claims โŒ˜R; the blunt reset has no key:\n%s", block)1368 }1369 span := block[reset:]1370 if closes := strings.Index(span, "</span>"); closes >= 0 {1371 span = span[:closes]1372 }1373 if strings.Contains(span, guard) {1374 t.Errorf("โŒ˜R carries the editable-target guard, so a page a reader is "+1375 "typing in cannot be reset:\n%s", span)1376 }1377 if !strings.Contains(span, ` prevent"`) {1378 t.Errorf("โŒ˜R does not swallow the browser's own reload, which would "+1379 "race the redirect:\n%s", span)1380 }13811382 // Every filter says the key was pressed rather than held. A key held down1383 // on a route that writes a file is sixty writes a second.1384 spans := strings.Count(block, "hx-trigger=")1385 if repeats := strings.Count(block, "!event.repeat"); repeats != spans {1386 t.Errorf("%d of %d shortcuts guard the autorepeat", repeats, spans)1387 }1388 // And every one of them answers the key it acts on, which is what stops1389 // the machine sounding its rejection tone at every press.1390 if prevented := strings.Count(block, ` prevent"`); prevented != spans {1391 t.Errorf("%d of %d shortcuts swallow the keystroke they answer", prevented, spans)1392 }1393 // The seam's half is not here, and must not be: none of it moves discrete1394 // state, and one of them cannot be a request at all.1395 for _, absent := range []string{"'z'", "'`'", "'k'", "contextmenu"} {1396 if strings.Contains(block, absent) {1397 t.Errorf("the shell declares %s, which belongs to the seam:\n%s", absent, block)1398 }1399 }1400}14011402func TestDetailFragment(t *testing.T) {1403 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1404 got := get(t, handler, "/fragments/detail/1849?volume=tunic", nil)1405 if got.Code != http.StatusOK {1406 t.Fatalf("the detail fragment answered %d", got.Code)1407 }1408 // What the fragment must always carry is its own container, because the1409 // region is swapped into it by id.1410 if !strings.Contains(got.Body.String(), `id="atlas-detail"`) {1411 t.Errorf("the fragment carries no card container:\n%s", got.Body)1412 }1413 // This volume's payload holds no features, so nothing resolves and the1414 // card comes back closed -- which it says with the `hidden` the carried1415 // assets/css/pin-detail.css reads, not by being empty. The states a reader1416 // walks through are held in tests/island; this is the one the fragment1417 // route reaches on its own.1418 if !strings.Contains(got.Body.String(), `class="pin-detail" hidden>`) {1419 t.Errorf("a card with nothing to show does not say it is closed:\n%s", got.Body)1420 }1421 if got := get(t, handler, "/fragments/detail/1849", nil); got.Code != http.StatusBadRequest {1422 t.Errorf("a fragment naming no volume answered %d", got.Code)1423 }1424}14251426// zonedVolume is a volume whose world is two shape collections with named1427// features, which is what a feature index needs to have rows: a shape is1428// indexed only once it has ground to draw (internal/app/world.go, Drawn).1429func zonedVolume() *fakeVolume {1430 held := volume("tunic", "TUNIC", tunicStamp)1431 ring := func(west, east string) string {1432 return `[{"type":"Polygon","coordinates":[[[` + west + `,44.00],[` + east +1433 `,44.00],[` + east + `,44.05],[` + west + `,44.05],[` + west + `,44.00]]]}]`1434 }1435 held.entries["worlds/overworld.json"] = []byte(`{"lenses":[],"collections":[1436 {"id":900,"title":"Zoning","kind":"area","attrs":{"atlas.geometry.kind":"area"},"features":[1437 {"id":91,"title":"R1","geometry":` + ring("-121.30", "-121.25") + `},1438 {"id":92,"title":"R2","geometry":` + ring("-121.20", "-121.15") + `},1439 {"id":93,"title":"R3","geometry":` + ring("-121.10", "-121.05") + `}]},1440 {"id":901,"title":"Watersheds","kind":"area","attrs":{"atlas.geometry.kind":"area"},"features":[1441 {"id":94,"title":"Tumalo","geometry":` + ring("-121.00", "-120.95") + `}]}1442 ]}`)1443 return held1444}14451446func TestFeaturePagesComeFromPhysicalPartitions(t *testing.T) {1447 handler, _ := newApp(t, zonedVolume())1448 base := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp)1449 got := get(t, handler, base+"/features/overworld%2Fset%2F900.json?limit=2&minX=-1000000000000&minY=-1000000000000&maxX=1000000000000&maxY=1000000000000", nil)1450 if got.Code != http.StatusOK {1451 t.Fatalf("feature page answered %d: %s", got.Code, got.Body.String())1452 }1453 if got.Header().Get("X-Atlas-Feature-Partitions") != "1" {1454 t.Fatalf("feature page read %q partitions, want one", got.Header().Get("X-Atlas-Feature-Partitions"))1455 }1456 var page struct {1457 Features []struct {1458 ID string `json:"id"`1459 } `json:"features"`1460 Next string `json:"next"`1461 }1462 if err := json.Unmarshal(got.Body.Bytes(), &page); err != nil {1463 t.Fatal(err)1464 }1465 if len(page.Features) != 2 || page.Next == "" {1466 t.Fatalf("feature page = %d features, next %q", len(page.Features), page.Next)1467 }1468 invalid := get(t, handler, base+"/features/overworld%2Fset%2F900.json?minX=1", nil)1469 if invalid.Code != http.StatusBadRequest {1470 t.Fatalf("incomplete feature bounds answered %d", invalid.Code)1471 }1472}14731474func TestExplorerPageZeroDoesNotReadFeaturePartitions(t *testing.T) {1475 volume := zonedVolume()1476 volume.demandAddressed = true1477 handler, _ := newApp(t, volume)1478 page := get(t, handler, "/v/tunic/overworld", nil)1479 if page.Code != http.StatusOK {1480 t.Fatalf("explorer answered %d: %s", page.Code, page.Body.String())1481 }1482 if volume.featurePageCalls != 0 {1483 t.Fatalf("page zero read %d feature pages", volume.featurePageCalls)1484 }1485}14861487// zoneOnlyButton is one zone row's exclusive control, from the attribute that1488// names the zone to the end of the button that carries it.1489func zoneOnlyButton(page, zone string) string {1490 at := strings.Index(page, `data-zone-only="`+zone+`"`)1491 if at < 0 {1492 return ""1493 }1494 rest := page[at:]1495 if end := strings.Index(rest, "</button>"); end >= 0 {1496 return rest[:end]1497 }1498 return rest1499}15001501// The exclusive control on a zone row: this ground, and no other.1502//1503// It is this application's own addition rather than a port. The reference implementation1504// gave the only-button to collections and to sections alone, and its zone1505// highlights only ever accumulated (frontend/src/areas.js, toggleZoneHighlight)1506// -- so a reader who wanted one zone had to clear the set by hand, or isolate1507// the collection, which answers the different and heavier question of putting1508// every other collection away.1509//1510// What this holds is the whole of the move: an accumulated set is replaced1511// rather than added to, the press that made a zone exclusive is the press that1512// gives the highlights back, and a highlight still brings its own collection1513// out of hiding on the way -- asking to look at a piece of ground and keeping1514// it put away cannot both be meant, however the asking was spelled.1515func TestOneZoneCanBeAskedForExclusively(t *testing.T) {1516 handler, host := newApp(t, zonedVolume())1517 session := func(t *testing.T) app.Session {1518 t.Helper()1519 held, err := host.sessions.Load("volume.tunic.json")1520 if err != nil {1521 t.Fatalf("no session was written: %v", err)1522 }1523 var out app.Session1524 if err := json.Unmarshal(held, &out); err != nil {1525 t.Fatal(err)1526 }1527 return out1528 }1529 highlight := func(t *testing.T, form url.Values) *httptest.ResponseRecorder {1530 t.Helper()1531 form["volume"] = []string{"tunic"}1532 got := post(t, handler, "/session/highlight", form)1533 if got.Code != http.StatusOK {1534 t.Fatalf("/session/highlight answered %d: %s", got.Code, got.Body)1535 }1536 return got1537 }15381539 // The accumulating form, which is what the row's right button asks and1540 // what this control is not: two zones highlighted is two zones.1541 highlight(t, url.Values{"feature": {"overworld/feature/91"}})1542 highlight(t, url.Values{"feature": {"overworld/feature/92"}})1543 if held := session(t).Highlighted; len(held) != 2 {1544 t.Fatalf("highlighting twice did not accumulate: %v", held)1545 }1546 // The collection the exclusive zone belongs to is away, so the ride-along1547 // has something to do.1548 if got := post(t, handler, "/session/collections",1549 url.Values{"volume": {"tunic"}, "collection": {"overworld/layer/901"}, "visible": {"0"}}); got.Code != http.StatusOK {1550 t.Fatalf("hiding a collection answered %d", got.Code)1551 }15521553 // The exclusive form replaces the set rather than joining it.1554 answer := highlight(t, url.Values{"feature": {"overworld/feature/94"}, "only": {"1"}})1555 held := session(t)1556 if len(held.Highlighted) != 1 || held.Highlighted[0] != "overworld/feature/94" {1557 t.Errorf("the exclusive press did not replace the set: %v", held.Highlighted)1558 }1559 if contains(held.Hidden, "overworld/layer/901") {1560 t.Errorf("the exclusive press left its own collection hidden: %v", held.Hidden)1561 }1562 // It moves what a highlight moves, and nothing else: the same three1563 // regions the accumulating form declares (docs/app.md ยง4.3).1564 for _, target := range []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"} {1565 if !strings.Contains(answer.Body.String(), `target="`+target+`"`) {1566 t.Errorf("the answer carries no partial for %s", target)1567 }1568 }15691570 // Pressing it again on the zone that is already alone is the way out. It1571 // is the isolate chip's own toggle, one row further in: a control that set1572 // a filter is the control that lifts it.1573 highlight(t, url.Values{"feature": {"overworld/feature/94"}, "only": {"1"}})1574 if held := session(t).Highlighted; len(held) != 0 {1575 t.Errorf("pressing the exclusive control again did not clear the highlights: %v", held)1576 }15771578 // And a zone that is alone by another route -- highlighted one at a time1579 // until one was left -- is exclusive too, because the state is derived1580 // from the set rather than from which button reached it.1581 highlight(t, url.Values{"feature": {"overworld/feature/93"}})1582 highlight(t, url.Values{"feature": {"overworld/feature/93"}, "only": {"1"}})1583 if held := session(t).Highlighted; len(held) != 0 {1584 t.Errorf("a lone highlight was not treated as an exclusive one: %v", held)1585 }1586}15871588// The control on the page: every zone row wears one, it says what it would do,1589// and the zone that is the whole of the highlight is the one that reads as1590// pressed.1591func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {1592 handler, _ := newApp(t, zonedVolume())1593 if got := post(t, handler, "/session/highlight",1594 url.Values{"volume": {"tunic"}, "feature": {"overworld/feature/93"}, "only": {"1"}}); got.Code != http.StatusOK {1595 t.Fatalf("/session/highlight answered %d: %s", got.Code, got.Body)1596 }1597 page := get(t, handler, "/v/tunic/overworld", nil)1598 if page.Code != http.StatusOK {1599 t.Fatalf("the explorer answered %d", page.Code)1600 }1601 shell := page.Body.String()16021603 // One control per zone, and the request it carries is the exclusive form1604 // of the highlight concern -- not the isolate route the collection rows1605 // use, which would put every other collection away.1606 for _, zone := range []string{"overworld/feature/91", "overworld/feature/92", "overworld/feature/93", "overworld/feature/94"} {1607 markup := zoneOnlyButton(shell, zone)1608 if markup == "" {1609 t.Fatalf("the zone %s has no exclusive control:\n%s", zone, legendOf(t, shell))1610 }1611 if !strings.Contains(markup, `hx-post="/session/highlight"`) {1612 t.Errorf("the zone %s control does not ask the highlight concern:\n%s", zone, markup)1613 }1614 if !strings.Contains(markup, `"only":"1"`) {1615 t.Errorf("the zone %s control does not ask exclusively:\n%s", zone, markup)1616 }1617 if !strings.Contains(markup, `"feature":"`+zone+`"`) {1618 t.Errorf("the zone %s control names another feature:\n%s", zone, markup)1619 }1620 }1621 // It is the same control the collection rows wear, so it reads and draws1622 // as one: the carried `.only-button` rule is what reveals it.1623 if !strings.Contains(zoneOnlyButton(shell, "overworld/feature/91"), `aria-label="Exclusively R1"`) {1624 t.Errorf("the control does not say what it does:\n%s", zoneOnlyButton(shell, "overworld/feature/91"))1625 }1626 // And the pressed state is the state, not the press: R3 is the whole of1627 // what is highlighted, so its control is the one that reads pressed.1628 if !strings.Contains(zoneOnlyButton(shell, "overworld/feature/93"), `aria-pressed="true"`) {1629 t.Errorf("the exclusive zone's control does not read pressed:\n%s", zoneOnlyButton(shell, "overworld/feature/93"))1630 }1631 for _, zone := range []string{"overworld/feature/91", "overworld/feature/92", "overworld/feature/94"} {1632 if !strings.Contains(zoneOnlyButton(shell, zone), `aria-pressed="false"`) {1633 t.Errorf("the zone %s reads exclusive while %s is:\n%s", zone, "93", zoneOnlyButton(shell, zone))1634 }1635 }1636 // The control is a sibling of the row's own button and not a child of it,1637 // which is the whole of how one click cannot be the other: an event1638 // reaches its ancestors and never its siblings.1639 row := shell[strings.Index(shell, `data-zone="overworld/feature/93"`):]1640 row = row[:strings.Index(row, `data-zone-only="overworld/feature/93"`)]1641 if strings.Count(row, "</button>") != 1 {1642 t.Errorf("the exclusive control is nested inside the row's own button:\n%s", row)1643 }1644}16451646// A legend row wears the collection's artwork, and only a collection with no1647// artwork wears its initials.1648//1649// The reference set `--pin-icon` and the `has-source-icon` class on the row's1650// icon cell and emptied its text, or wrote the initials into it1651// (frontend/src/theme.js, applyCategoryGlyph); the stylesheet that draws one1652// from the other came over verbatim, so the whole of the port is what the1653// template emits. A city is the volume that showed it missing -- its1654// collections are enriched rather than curated, so they carry a standard glyph1655// and no colour of their own -- and it is the shape the payload below has.1656func TestALegendRowWearsTheCollectionsArtwork(t *testing.T) {1657 held := volume("bend-or", "Bend, Oregon", tunicStamp)1658 held.entries["icons/std--maki-monument.svg"] = []byte("<svg/>")1659 held.entries["worlds/overworld.json"] = []byte(`{"lenses":[],"collections":[1660 {"id":1496244488,"title":"Historic Resources","kind":"point","group":"Heritage",1661 "icon":"historic-resources","iconAsset":"std--maki-monument.svg",1662 "attrs":{"atlas.geometry.kind":"point","atlas.icon.kind":"glyph"}},1663 {"id":42,"title":"Fire Stations","kind":"point","group":"Heritage",1664 "attrs":{"atlas.geometry.kind":"point"}}1665 ]}`)1666 handler, _ := newApp(t, held)16671668 page := get(t, handler, "/v/bend-or/overworld", nil)1669 if page.Code != http.StatusOK {1670 t.Fatalf("the explorer answered %d", page.Code)1671 }1672 shell := page.Body.String()1673 base := "/data/v/bend-or/" + bundle.ShortStamp(tunicStamp)1674 digest := sha256.Sum256([]byte("<svg/>"))16751676 // The collection that carries a glyph: the artwork, named the way the1677 // seam names it, and no initials to draw over it.1678 wearing := `<span class="category-icon has-source-icon" style="--pin-icon: ` +1679 `url('` + fmt.Sprintf("%s/assets/%x", base, digest) + `')" title="historic-resources"></span>`1680 if !strings.Contains(shell, wearing) {1681 t.Errorf("the Historic Resources row draws no artwork; want\n\t%s\nin\n%s", wearing, legendOf(t, shell))1682 }1683 // The collection that carries none: its initials, and nothing that would1684 // make the stylesheet look for a picture.1685 if want := `<span class="category-icon" title="Fire Stations">FS</span>`; !strings.Contains(shell, want) {1686 t.Errorf("a collection with no artwork lost its initials; want\n\t%s\nin\n%s", want, legendOf(t, shell))1687 }16881689 // And the colour: first in the payload is first on the wheel, which is1690 // the colour the seam draws the same collection's pins in1691 // (render/chart/styles.ts, collectionColor). The two used to disagree1692 // -- the legend hashed the collection's id -- and a city, whose1693 // collections declare no colour of their own, is where it showed.1694 if want := `class="category-row" style="--pin-color: #4fb3d5"`; !strings.Contains(shell, want) {1695 t.Errorf("the first collection is not wearing the first colour of the wheel:\n%s", legendOf(t, shell))1696 }1697}16981699// legendOf is the sidebar alone, for a failure message that is readable.1700func legendOf(t *testing.T, shell string) string {1701 t.Helper()1702 opens := strings.Index(shell, `<aside id="atlas-legend"`)1703 if opens < 0 {1704 return shell1705 }1706 closes := strings.Index(shell[opens:], "</aside>")1707 if closes < 0 {1708 return shell[opens:]1709 }1710 return shell[opens : opens+closes]1711}17121713// contains is the membership question a handful of checks above ask of a1714// sorted set.1715func contains(set []string, member string) bool {1716 for _, held := range set {1717 if held == member {1718 return true1719 }1720 }1721 return false1722}17231724func TestStaticWithoutASeam(t *testing.T) {1725 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1726 if got := get(t, handler, "/static/app.js", nil); got.Code != http.StatusNotFound {1727 t.Errorf("a host that mounted no seam answered %d, want 404: the application works without it",1728 got.Code)1729 }1730}17311732// The one thing a reset keeps is the reader's place. A record that named a1733// lens comes back as a record naming only the world and that lens -- the1734// arrangement, the filters, the cameras and the rest go, and the next load1735// arranges a fresh record around the place the reader was already standing.1736func TestSessionResetKeepsTheWorldAndTheLens(t *testing.T) {1737 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))1738 if page := get(t, handler, "/v/tunic/overworld", nil); page.Code != http.StatusOK {1739 t.Fatalf("the explorer answered %d", page.Code)1740 }1741 for _, step := range []struct {1742 concern string1743 form url.Values1744 }{1745 {"lens", url.Values{"lens": {"second-look"}}},1746 {"collections", url.Values{"collection": {"7"}, "visible": {"0"}}},1747 {"highlight", url.Values{"feature": {"1849"}}},1748 } {1749 form := url.Values{"volume": {"tunic"}}1750 for name, values := range step.form {1751 form[name] = values1752 }1753 if got := post(t, handler, "/session/"+step.concern, form); got.Code != http.StatusOK {1754 t.Fatalf("/session/%s answered %d: %s", step.concern, got.Code, got.Body)1755 }1756 }17571758 if got := post(t, handler, "/session/reset", url.Values{"volume": {"tunic"}}); got.Code != http.StatusNoContent {1759 t.Fatalf("the reset answered %d: %s", got.Code, got.Body)1760 }17611762 after := sessionRecord(t, host, "volume.tunic.json")1763 if after.World != "overworld" || after.Lens != "second-look" {1764 t.Errorf("the reset lost the reader's place: world %q lens %q", after.World, after.Lens)1765 }1766 if len(after.Hidden) != 0 || len(after.Highlighted) != 0 || after.Arranged ||1767 after.Search != "" || len(after.Cameras) != 0 || after.Selected != "" {1768 t.Errorf("the reset kept more than the place: %+v", after)1769 }1770}17711772// The retired HTTP transcript held the catalog byte for byte; what those bytes1773// were saying is held here instead. A client plans its whole session off this1774// one document -- the stamp is how it knows a build moved, the tile grid is1775// how it addresses the pyramid before any payload has arrived, and the world's1776// counts and updatedAt are what the library card prints -- so each is checked1777// as the manifest's own value carried through, not as a recorded string.1778func TestCatalogCarriesTheBuildAndItsGrid(t *testing.T) {1779 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1780 got := get(t, handler, "/data/catalog.json", nil)1781 if got.Code != http.StatusOK {1782 t.Fatalf("catalog answered %d", got.Code)1783 }1784 var catalog struct {1785 Volumes []struct {1786 Stamp string `json:"stamp"`1787 TileGrid bundle.TileGrid `json:"tileGrid"`1788 Worlds []struct {1789 Title string `json:"title"`1790 Points int `json:"points"`1791 Paths int `json:"paths"`1792 Areas int `json:"areas"`1793 UpdatedAt string `json:"updatedAt"`1794 } `json:"worlds"`1795 } `json:"volumes"`1796 }1797 if err := json.Unmarshal(got.Body.Bytes(), &catalog); err != nil {1798 t.Fatal(err)1799 }1800 if len(catalog.Volumes) != 1 {1801 t.Fatalf("catalog lists %d volumes", len(catalog.Volumes))1802 }1803 held := catalog.Volumes[0]1804 // The whole stamp, not the twelve characters the base URL carries: the1805 // short form names a directory and the long form names the build.1806 if held.Stamp != tunicStamp {1807 t.Errorf("stamp = %q, want the manifest's own", held.Stamp)1808 }1809 if want := (bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192}); held.TileGrid != want {1810 t.Errorf("tileGrid = %+v, want the manifest's %+v", held.TileGrid, want)1811 }1812 if len(held.Worlds) != 1 {1813 t.Fatalf("the volume lists %d worlds", len(held.Worlds))1814 }1815 world := held.Worlds[0]1816 if world.Title != "Overworld" || world.Points != 3 || world.Paths != 0 || world.Areas != 0 {1817 t.Errorf("the world's card reads %+v, not the manifest's counts", world)1818 }1819 if world.UpdatedAt != "2026-01-01T00:00:00Z" {1820 t.Errorf("updatedAt = %q, want the manifest's own", world.UpdatedAt)1821 }1822}18231824// A climb out of the volume. The retired transcript recorded this exchange1825// through an ordinary client, so the 404 it holds sits at the end of the1826// redirect the router's own path cleaning answers first; the recorder here1827// sees the walk one hop at a time, and reproduces it: every hop stays on this1828// application, and the end of it is a refusal, never a file.1829func TestDataPlaneRefusesAClimbOutOfTheVolume(t *testing.T) {1830 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1831 path := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp) + "/worlds/../../../../etc/passwd"18321833 got := get(t, handler, path, nil)1834 for hop := 0; got.Code >= 300 && got.Code < 400; hop++ {1835 if hop == 5 {1836 t.Fatal("the climb is still being redirected after five hops")1837 }1838 where := got.Header().Get("Location")1839 if !strings.HasPrefix(where, "/") {1840 t.Fatalf("the climb was redirected off the application, to %q", where)1841 }1842 got = get(t, handler, where, nil)1843 }1844 if got.Code != http.StatusNotFound {1845 t.Errorf("the climb ended at %d, want 404: %s", got.Code, got.Body)1846 }1847}18481849// The redirect is really sent. The desktop host cannot show a redirect to its1850// webview, so it follows the handler's redirects itself (redirects.go) -- and1851// it only follows a Location that names a path on this application. Both1852// halves of that arrangement are the handler's to honor: / answers with an1853// actual 302 rather than the followed page, and the doorway it names is a1854// path, not an absolute URL that would belong to somebody else's document.1855func TestTheDoorwayIsARealRedirectToAPath(t *testing.T) {1856 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1857 got := get(t, handler, "/", nil)1858 if got.Code != http.StatusFound {1859 t.Fatalf("/ answered %d, want the 302 that is really sent", got.Code)1860 }1861 where := got.Header().Get("Location")1862 if !strings.HasPrefix(where, "/") || strings.HasPrefix(where, "//") {1863 t.Errorf("Location = %q, want a path on this application", where)1864 }1865 // The body is Go's own two words, not the page at the end of the doorway:1866 // a handler that pre-followed would be a second copy of the world page1867 // nobody asked for, painted over the one the client fetches next.1868 if body := got.Body.String(); strings.Contains(body, "<html") || strings.Contains(body, "atlas-shell") {1869 t.Errorf("the doorway answered with a whole page instead of a redirect:\n%s", body)1870 }1871}