1package app_test23import (4 "bytes"5 "context"6 "encoding/json"7 "errors"8 "io"9 "net/http"10 "net/http/httptest"11 "net/url"12 "strings"13 "testing"1415 "github.com/FelineStateMachine/atlas/format/bundle"16 "github.com/FelineStateMachine/atlas/internal/app"17 "github.com/FelineStateMachine/atlas/internal/app/hostenv"18)1920// The handler is tested through a host that is entirely in memory, which is21// the point of hostenv: nothing here needs a directory, a bundle file, or a22// window, so what these tests exercise is the handler and not the machine.2324type fakeVolume struct {25 manifest bundle.Manifest26 entries map[string][]byte27}2829func (v *fakeVolume) Manifest() bundle.Manifest { return v.manifest }3031func (v *fakeVolume) Open(name string) (io.ReadCloser, int64, error) {32 held, ok := v.entries[name]33 if !ok {34 return nil, 0, errors.New("no such entry")35 }36 return io.NopCloser(bytes.NewReader(held)), int64(len(held)), nil37}3839type fakeVolumes struct {40 volumes []hostenv.Volume41 location string4243 // arriving is what the next Install adds, and installs is what it was44 // asked to take in. already makes it the successful import that copied45 // nothing, because the library was holding this exact build already.46 arriving hostenv.Volume47 installs [][]byte48 refuse error49 already bool50}5152func (s *fakeVolumes) Volumes() []hostenv.Volume { return s.volumes }53func (s *fakeVolumes) Location() string { return s.location }5455func (s *fakeVolumes) Rescan() ([]string, error) { return nil, nil }5657func (s *fakeVolumes) Install(name string, content io.Reader) (hostenv.Installed, error) {58 data, err := io.ReadAll(content)59 if err != nil {60 return hostenv.Installed{}, err61 }62 s.installs = append(s.installs, data)63 if s.refuse != nil {64 return hostenv.Installed{}, s.refuse65 }66 if s.arriving == nil {67 return hostenv.Installed{}, errors.New("nothing to install")68 }69 manifest := s.arriving.Manifest()70 s.volumes = append(s.volumes, s.arriving)71 return hostenv.Installed{72 Slug: manifest.Volume.Slug,73 Title: manifest.Volume.Title,74 Stamp: manifest.Version.Stamp,75 Already: s.already,76 Changed: []string{manifest.Volume.Slug},77 }, nil78}7980type fakeHost struct {81 volumes *fakeVolumes82 sessions hostenv.SessionStore83 pick func(ctx context.Context) (io.ReadCloser, string, error)84}8586func (h *fakeHost) Volumes() hostenv.VolumeStore { return h.volumes }87func (h *fakeHost) Sessions() hostenv.SessionStore { return h.sessions }8889func (h *fakeHost) PickFile(ctx context.Context) (io.ReadCloser, string, error) {90 if h.pick == nil {91 return nil, "", hostenv.ErrNotAvailable92 }93 return h.pick(ctx)94}9596// volume builds one serving volume with the entries a data-plane test asks97// for.98func volume(slug, title, stamp string, worlds ...bundle.WorldEntry) *fakeVolume {99 if len(worlds) == 0 {100 worlds = []bundle.WorldEntry{{Slug: "overworld", Title: "Overworld", Points: 3, UpdatedAt: "2026-01-01T00:00:00Z"}}101 }102 return &fakeVolume{103 manifest: bundle.Manifest{104 Format: bundle.Format,105 FormatVersion: bundle.FormatVersion,106 Conventions: 2,107 Volume: bundle.Volume{Slug: slug, Title: title},108 Version: bundle.Version{Stamp: stamp, CreatedAt: "2026-01-01T00:00:00Z"},109 TileGrid: bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192},110 Worlds: worlds,111 },112 entries: map[string][]byte{113 "worlds/overworld.json": []byte(`{"lenses":[],"collections":[]}`),114 "worlds/overworld.bin": []byte("ATLASLOC"),115 "worlds/overworld.text": []byte(`{}`),116 "icons/marker.svg": []byte("<svg/>"),117 "tiles/overworld/0/0/0.jpg": []byte("raster"),118 },119 }120}121122const (123 tunicStamp = "13d5657ed9038808e5fe12ef44e769b40297e9d720e53376f430f224128f2dfc"124 marsStamp = "68e141f26b1a8808e5fe12ef44e769b40297e9d720e53376f430f224128f2dfc"125)126127func newApp(t *testing.T, volumes ...hostenv.Volume) (*app.App, *fakeHost) {128 t.Helper()129 host := &fakeHost{130 volumes: &fakeVolumes{volumes: volumes, location: "/library"},131 sessions: hostenv.NewMemorySessions(),132 }133 return app.New(host, app.Options{}), host134}135136func get(t *testing.T, handler http.Handler, path string, headers map[string]string) *httptest.ResponseRecorder {137 t.Helper()138 request := httptest.NewRequest(http.MethodGet, path, nil)139 for name, value := range headers {140 request.Header.Set(name, value)141 }142 recorder := httptest.NewRecorder()143 handler.ServeHTTP(recorder, request)144 return recorder145}146147func post(t *testing.T, handler http.Handler, path string, form url.Values) *httptest.ResponseRecorder {148 t.Helper()149 request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))150 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")151 recorder := httptest.NewRecorder()152 handler.ServeHTTP(recorder, request)153 return recorder154}155156func TestCatalogComposition(t *testing.T) {157 handler, _ := newApp(t,158 volume("tunic", "TUNIC", tunicStamp),159 volume("mars", "Mars", marsStamp))160161 got := get(t, handler, "/data/catalog.json", nil)162 if got.Code != http.StatusOK {163 t.Fatalf("catalog answered %d", got.Code)164 }165 if cache := got.Header().Get("Cache-Control"); cache != "no-store" {166 t.Errorf("catalog Cache-Control = %q, want no-store: it is the one response whose job is to be current", cache)167 }168 if kind := got.Header().Get("Content-Type"); kind != "application/json" {169 t.Errorf("catalog Content-Type = %q", kind)170 }171172 var catalog struct {173 Volumes []struct {174 Slug string `json:"slug"`175 Title string `json:"title"`176 Stamp string `json:"stamp"`177 Base string `json:"base"`178 Worlds []struct {179 Slug string `json:"slug"`180 } `json:"worlds"`181 } `json:"volumes"`182 BundlesDir string `json:"bundlesDir"`183 }184 if err := json.Unmarshal(got.Body.Bytes(), &catalog); err != nil {185 t.Fatal(err)186 }187 if len(catalog.Volumes) != 2 {188 t.Fatalf("catalog lists %d volumes", len(catalog.Volumes))189 }190 // Volumes are listed by title, which is the order a reader meets them in.191 if catalog.Volumes[0].Slug != "mars" || catalog.Volumes[1].Slug != "tunic" {192 t.Errorf("catalog order is %s, %s; want by title",193 catalog.Volumes[0].Slug, catalog.Volumes[1].Slug)194 }195 if want := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp); catalog.Volumes[1].Base != want {196 t.Errorf("base = %q, want %q", catalog.Volumes[1].Base, want)197 }198 if catalog.BundlesDir != "/library" {199 t.Errorf("bundlesDir = %q, want the store's own location", catalog.BundlesDir)200 }201}202203func TestContentPlane(t *testing.T) {204 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))205 base := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp)206207 served := []struct {208 name string209 path string210 kind string211 size int212 }{213 {"a world payload", base + "/worlds/overworld.json", "application/json", 30},214 {"the packed locations", base + "/worlds/overworld.bin", "application/octet-stream", 8},215 {"the deferred prose", base + "/worlds/overworld.text", "application/json", 2},216 {"an icon", base + "/icons/marker.svg", "image/svg+xml", 6},217 {"a tile", base + "/tiles/overworld/0/0/0.jpg", "image/jpeg", 6},218 }219 for _, tt := range served {220 t.Run(tt.name, func(t *testing.T) {221 got := get(t, handler, tt.path, nil)222 if got.Code != http.StatusOK {223 t.Fatalf("%s answered %d", tt.path, got.Code)224 }225 if kind := got.Header().Get("Content-Type"); kind != tt.kind {226 t.Errorf("Content-Type = %q, want %q", kind, tt.kind)227 }228 if got.Body.Len() != tt.size {229 t.Errorf("body is %d bytes, want %d", got.Body.Len(), tt.size)230 }231 if cache := got.Header().Get("Cache-Control"); cache != "private, max-age=31536000, immutable" {232 t.Errorf("Cache-Control = %q: a stamped URL names one build forever", cache)233 }234 })235 }236237 refused := []struct {238 name string239 path string240 }{241 {"a stamp that is not the serving build", "/data/v/tunic/000000000000/worlds/overworld.json"},242 {"a volume that is not installed", "/data/v/not-a-volume/" + bundle.ShortStamp(tunicStamp) + "/worlds/overworld.json"},243 {"an entry outside worlds, tiles and icons", base + "/atlas.json"},244 {"an extension the plane names no type for", base + "/worlds/overworld.txt"},245 {"a world the bundle does not hold", base + "/worlds/not-a-world.json"},246 {"a path with no extension at all", base + "/worlds/overworld"},247 {"a path under the shell that is not a page", "/not-a-page"},248 }249 for _, tt := range refused {250 t.Run(tt.name, func(t *testing.T) {251 got := get(t, handler, tt.path, nil)252 if got.Code != http.StatusNotFound {253 t.Errorf("%s answered %d, want 404", tt.path, got.Code)254 }255 })256 }257}258259// A Range request is answered whole: 200, the full body, no Accept-Ranges.260// Tiles are stored uncompressed so that ranges *could* be served, and data.go261// says why they deliberately are not; this test is what goes red if somebody262// implements them without deciding to.263func TestContentDoesNotServeRanges(t *testing.T) {264 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))265 path := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp) + "/tiles/overworld/0/0/0.jpg"266267 got := get(t, handler, path, map[string]string{"Range": "bytes=0-2"})268 if got.Code != http.StatusOK {269 t.Fatalf("a range request answered %d, want 200 with the whole body", got.Code)270 }271 if got.Body.String() != "raster" {272 t.Errorf("body = %q, want the whole entry", got.Body.String())273 }274 if accept := got.Header().Get("Accept-Ranges"); accept != "" {275 t.Errorf("Accept-Ranges = %q, want the plane to make no such offer", accept)276 }277}278279func TestHomeSendsAReaderBackWhereTheyWere(t *testing.T) {280 handler, host := newApp(t,281 volume("tunic", "TUNIC", tunicStamp),282 volume("mars", "Mars", marsStamp))283284 // Nobody has been anywhere yet: the first volume in catalog order stands285 // in for "where you were".286 got := get(t, handler, "/", nil)287 if got.Code != http.StatusFound {288 t.Fatalf("/ answered %d, want a redirect to a world", got.Code)289 }290 if where := got.Header().Get("Location"); where != "/v/mars/overworld" {291 t.Errorf("/ sent the reader to %q", where)292 }293294 // Visiting a world remembers it, and / follows.295 if page := get(t, handler, "/v/tunic/overworld", nil); page.Code != http.StatusOK {296 t.Fatalf("the explorer answered %d", page.Code)297 }298 got = get(t, handler, "/", nil)299 if where := got.Header().Get("Location"); where != "/v/tunic/overworld" {300 t.Errorf("after visiting tunic, / sent the reader to %q", where)301 }302 if _, err := host.sessions.Load("volume.tunic.json"); err != nil {303 t.Errorf("visiting a world wrote no session record: %v", err)304 }305}306307func TestHomeWithNothingInstalled(t *testing.T) {308 handler, _ := newApp(t)309 got := get(t, handler, "/", nil)310 if got.Code != http.StatusOK {311 t.Fatalf("an empty library answered %d, want the library card", got.Code)312 }313 if !strings.Contains(got.Body.String(), "/library") {314 t.Error("the empty state does not say where a bundle goes")315 }316}317318func TestExplorerRefusesWhatIsNotThere(t *testing.T) {319 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))320 for _, path := range []string{"/v/not-a-volume/overworld", "/v/tunic/not-a-world"} {321 if got := get(t, handler, path, nil); got.Code != http.StatusNotFound {322 t.Errorf("%s answered %d, want 404", path, got.Code)323 }324 }325}326327// One route per concern, each answering with the regions it touched. The328// table is the contract: a concern that starts moving a different set of329// regions has changed the page's behavior and should say so here first.330func TestSessionConcerns(t *testing.T) {331 tests := []struct {332 concern string333 form url.Values334 targets []string335 check func(t *testing.T, s app.Session)336 }{337 {338 concern: "world",339 form: url.Values{"world": {"overworld"}},340 targets: []string{"#atlas-topbar", "#atlas-legend", "#atlas-dock", "#atlas-overview", "#atlas-viewport-state"},341 check: func(t *testing.T, s app.Session) {342 if s.World != "overworld" {343 t.Errorf("world = %q", s.World)344 }345 },346 },347 {348 concern: "lens",349 form: url.Values{"lens": {"satellite"}},350 // The legend and the dock are in the set because a lens can be a351 // different *layer* of a split sheet, and the ground under the352 // reader changes with it: which shapes the index lists and which353 // features the panel can name are the new layer's answers.354 targets: []string{355 "#atlas-topbar", "#atlas-legend", "#atlas-dock", "#atlas-overview",356 "#atlas-viewport-state",357 },358 check: func(t *testing.T, s app.Session) {359 if s.Lens != "satellite" {360 t.Errorf("lens = %q", s.Lens)361 }362 },363 },364 {365 concern: "collections",366 form: url.Values{"collection": {"7"}, "visible": {"0"}},367 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},368 check: func(t *testing.T, s app.Session) {369 if len(s.Hidden) != 1 || s.Hidden[0] != "7" {370 t.Errorf("hidden = %v", s.Hidden)371 }372 },373 },374 {375 concern: "sections",376 form: url.Values{"section": {"quests"}, "open": {"0"}},377 targets: []string{"#atlas-legend"},378 check: func(t *testing.T, s app.Session) {379 // A fresh record is arranged before the concern runs, and380 // the arrangement folds the viewer's own Zones section, so381 // what this asserts is that the named section joined it.382 if !contains(s.Collapsed, "quests") {383 t.Errorf("collapsed = %v", s.Collapsed)384 }385 },386 },387 {388 concern: "labels",389 form: url.Values{"collection": {"7"}, "policy": {"always"}},390 targets: []string{"#atlas-legend", "#atlas-viewport-state"},391 check: func(t *testing.T, s app.Session) {392 if s.Labels["7"] != "always" {393 t.Errorf("labels = %v", s.Labels)394 }395 },396 },397 {398 // Isolating is a move on the hide set rather than a state of399 // its own, and the chip is derived from what is hidden. With400 // nothing named, the chip's own way out -- show everything --401 // is what the route means.402 concern: "solo",403 form: url.Values{},404 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},405 check: func(t *testing.T, s app.Session) {406 if len(s.Hidden) != 0 {407 t.Errorf("hidden = %v", s.Hidden)408 }409 },410 },411 {412 concern: "expand",413 form: url.Values{"collection": {"7"}, "open": {"1"}},414 targets: []string{"#atlas-legend"},415 check: func(t *testing.T, s app.Session) {416 if len(s.Expanded) != 1 || s.Expanded[0] != "7" {417 t.Errorf("expanded = %v", s.Expanded)418 }419 },420 },421 {422 concern: "highlight",423 form: url.Values{"feature": {"31"}},424 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},425 check: func(t *testing.T, s app.Session) {426 if len(s.Highlighted) != 1 || s.Highlighted[0] != "31" {427 t.Errorf("highlighted = %v", s.Highlighted)428 }429 },430 },431 {432 concern: "overview",433 form: url.Values{"docked": {"1"}},434 targets: []string{"#atlas-overview"},435 check: func(t *testing.T, s app.Session) {436 if !s.Overview.Docked {437 t.Error("the overview did not dock")438 }439 },440 },441 {442 concern: "search",443 form: url.Values{"q": {"shrine"}},444 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"},445 check: func(t *testing.T, s app.Session) {446 if s.Search != "shrine" {447 t.Errorf("search = %q", s.Search)448 }449 },450 },451 {452 concern: "dock",453 form: url.Values{"open": {"1"}, "section": {"counts"}},454 targets: []string{"#atlas-dock"},455 check: func(t *testing.T, s app.Session) {456 if !s.Dock.Open || s.Dock.Section != "counts" {457 t.Errorf("dock = %+v", s.Dock)458 }459 },460 },461 {462 concern: "select",463 form: url.Values{"feature": {"1849"}},464 targets: []string{"#atlas-legend", "#atlas-dock", "#atlas-detail", "#atlas-viewport-state"},465 check: func(t *testing.T, s app.Session) {466 if s.Selected != "1849" || !s.Detail.Open {467 t.Errorf("selection = %q, detail = %+v", s.Selected, s.Detail)468 }469 },470 },471 {472 concern: "grid",473 // The field posts what the reader typed and the record holds an474 // address: four characters is past the geohash telescope's floor,475 // so what lands is the three-character cell the navigator would476 // have shown (internal/app/grid.go, normalizeCell).477 form: url.Values{"system": {"geohash"}, "cell": {"9q5c"}, "subgrid": {"2"}},478 targets: []string{"#atlas-grid-navigator", "#atlas-dock", "#atlas-viewport-state"},479 check: func(t *testing.T, s app.Session) {480 if s.Grid.System != "geohash" || s.Grid.Cell != "9q5" || s.Grid.Subgrid != 2 {481 t.Errorf("grid = %+v", s.Grid)482 }483 },484 },485 {486 concern: "sidebar",487 form: url.Values{"open": {"1"}},488 targets: []string{"#atlas-shell"},489 check: func(t *testing.T, s app.Session) {490 if s.Sidebar.Collapsed {491 t.Error("the sidebar did not open")492 }493 },494 },495 }496497 for _, tt := range tests {498 t.Run(tt.concern, func(t *testing.T) {499 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))500 form := url.Values{"volume": {"tunic"}}501 for name, values := range tt.form {502 form[name] = values503 }504 got := post(t, handler, "/session/"+tt.concern, form)505 if got.Code != http.StatusOK {506 t.Fatalf("/session/%s answered %d: %s", tt.concern, got.Code, got.Body)507 }508 if kind := got.Header().Get("Content-Type"); kind != "text/html; charset=utf-8" {509 t.Errorf("Content-Type = %q", kind)510 }511 body := got.Body.String()512 for _, target := range tt.targets {513 if !strings.Contains(body, `target="`+target+`"`) {514 t.Errorf("the answer carries no partial for %s:\n%s", target, body)515 }516 }517 // The regions the concern touches, and the island. Every answer518 // carries the island because every answer has just written the519 // record it publishes; it is inert and it moves nothing on520 // screen, so it is not one of the regions an interaction is521 // judged by, and it is not in the table above.522 if !strings.Contains(body, `target="#atlas-session-island"`) {523 t.Errorf("the answer carries no state island:\n%s", body)524 }525 if swaps := strings.Count(body, "<hx-partial"); swaps != len(tt.targets)+1 {526 t.Errorf("the answer carries %d partials, want the %d regions the concern touches and the island",527 swaps, len(tt.targets))528 }529530 held, err := host.sessions.Load("volume.tunic.json")531 if err != nil {532 t.Fatalf("no session was written: %v", err)533 }534 var session app.Session535 if err := json.Unmarshal(held, &session); err != nil {536 t.Fatal(err)537 }538 if session.Schema != app.SessionSchema {539 t.Errorf("record schema = %d", session.Schema)540 }541 tt.check(t, session)542 })543 }544}545546// The blunt reset, which is โR's whole job: this volume's record is deleted547// and the reader is put back into the volume with nothing remembered about it.548//549// It exists because a record that has gone wrong is not a thing anybody wants550// to diagnose one field at a time -- highlights culled at every pin, a filter551// with no row to press -- and the state a volume opens with is synthesized552// from the world itself, so throwing the record away *is* the way back.553//554// Two things this test holds that are easy to lose. The answer is a full round555// trip rather than a partial set, because after a reset there is no record for556// a region to be rendered from; and the last-volume pointer survives, because557// the reader is not leaving the volume, they are standing in it with a clean558// record.559func TestSessionResetForgetsTheVolumeAndComesBack(t *testing.T) {560 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))561562 // A reader who has been somewhere: the page opened, a collection put away,563 // a feature highlighted, a search typed, a grid opened and a camera564 // settled. Every one of these is a field the reset has to take with it.565 if page := get(t, handler, "/v/tunic/overworld", nil); page.Code != http.StatusOK {566 t.Fatalf("the explorer answered %d", page.Code)567 }568 for _, step := range []struct {569 concern string570 form url.Values571 }{572 {"collections", url.Values{"collection": {"7"}, "visible": {"0"}}},573 {"highlight", url.Values{"feature": {"1849"}}},574 {"search", url.Values{"q": {"mill"}}},575 {"grid", url.Values{"system": {"toggle"}}},576 {"view", url.Values{"world": {"overworld"}, "x": {"120.5"}, "y": {"-40"}, "zoom": {"6.25"}}},577 } {578 form := url.Values{"volume": {"tunic"}}579 for name, values := range step.form {580 form[name] = values581 }582 if got := post(t, handler, "/session/"+step.concern, form); got.Code != http.StatusOK {583 t.Fatalf("/session/%s answered %d: %s", step.concern, got.Code, got.Body)584 }585 }586 before := sessionRecord(t, host, "volume.tunic.json")587 if len(before.Hidden) == 0 || len(before.Highlighted) == 0 || before.Search == "" ||588 before.Grid.System == "" || len(before.Cameras) == 0 {589 t.Fatalf("the record under test was never arranged: %+v", before)590 }591592 got := post(t, handler, "/session/reset", url.Values{"volume": {"tunic"}})593 if got.Code != http.StatusNoContent {594 t.Fatalf("the reset answered %d: %s", got.Code, got.Body)595 }596 // The full refresh, as the header htmx acts on itself. A partial set would597 // be rendered out of a record that no longer exists, and a 303 would be598 // followed by fetch and swapped into a page that asked for no swap.599 if back := got.Header().Get("HX-Redirect"); back != "/v/tunic/overworld" {600 t.Errorf("the reset answers HX-Redirect %q, want the volume's own world back", back)601 }602 if got.Body.Len() != 0 {603 t.Errorf("the reset answers with a body:\n%s", got.Body)604 }605 if strings.Contains(got.Body.String(), "<hx-partial") {606 t.Errorf("the reset answers with partials over a record it just deleted:\n%s", got.Body)607 }608609 // The record is gone from the store, which is the whole of the reset.610 if _, err := host.sessions.Load("volume.tunic.json"); !errors.Is(err, hostenv.ErrNoSession) {611 t.Errorf("the volume's record survived the reset: %v", err)612 }613 // And the pointer did not go with it: the reader is still in this volume.614 if _, err := host.sessions.Load("app.json"); err != nil {615 t.Errorf("the reset forgot which volume the reader is in: %v", err)616 }617618 // What the redirect lands on: a page synthesized out of the world's own619 // arrangement. The island is the server's account of it, and it says the620 // three things the reader lost their hour to -- nothing hidden, nothing621 // highlighted, and a camera nobody has reported, which is the chart622 // fitting the world again.623 page := get(t, handler, "/v/tunic/overworld", nil)624 if page.Code != http.StatusOK {625 t.Fatalf("the page the reset comes back to answered %d", page.Code)626 }627 for _, want := range []string{`"hidden":[]`, `"center":null`, `"zoom":null`, `"dockFolded":true`} {628 if !strings.Contains(page.Body.String(), want) {629 t.Errorf("the island after a reset does not say %s:\n%s", want, page.Body)630 }631 }632 after := sessionRecord(t, host, "volume.tunic.json")633 if len(after.Hidden) != 0 || len(after.Highlighted) != 0 || after.Search != "" ||634 after.Grid.System != "" || after.Grid.Cell != "" || len(after.Cameras) != 0 ||635 after.Selected != "" || after.Detail.Open || after.Dock.Open || after.Overview.Docked ||636 after.Sidebar.Collapsed || len(after.Labels) != 0 {637 t.Errorf("the record the reset came back to is not fresh: %+v", after)638 }639 // The subdivision is the one field a fresh record does not leave at its640 // zero value: a grid a reader opens is a grid with its next level drawn.641 if after.Grid.Subgrid != 1 {642 t.Errorf("the fresh record's subgrid = %d, want the arrangement's own 1", after.Grid.Subgrid)643 }644}645646// A reset with nothing to reset. Pressing โR twice, or on a volume opened and647// never arranged, is the same request against a store that holds no record for648// it -- and deleting what is not there is what the caller asked for, so it649// answers exactly as the first press did and writes nothing behind it.650func TestSessionResetWithNoRecordIsANoOp(t *testing.T) {651 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))652653 got := post(t, handler, "/session/reset", url.Values{"volume": {"tunic"}})654 if got.Code != http.StatusNoContent {655 t.Fatalf("a reset with nothing to reset answered %d: %s", got.Code, got.Body)656 }657 // The world came from the manifest rather than from the record, because658 // there was no record to read it out of.659 if back := got.Header().Get("HX-Redirect"); back != "/v/tunic/overworld" {660 t.Errorf("HX-Redirect = %q, want the volume's first world", back)661 }662 names, err := host.sessions.Names()663 if err != nil {664 t.Fatal(err)665 }666 for _, name := range names {667 if name == "volume.tunic.json" {668 t.Errorf("a reset wrote the record it was asked to delete: %v", names)669 }670 }671}672673// sessionRecord reads one volume's record out of the store under test.674func sessionRecord(t *testing.T, host *fakeHost, name string) app.Session {675 t.Helper()676 held, err := host.sessions.Load(name)677 if err != nil {678 t.Fatalf("no session record %s: %v", name, err)679 }680 var session app.Session681 if err := json.Unmarshal(held, &session); err != nil {682 t.Fatal(err)683 }684 return session685}686687// The camera is the one continuous thing the server keeps, and it answers with688// nothing a reader can see: a swap of any of the chrome in response to a689// settling camera would fight the reader's own hand. What it does answer with690// is the state island, which is an inert script node -- no focus to lose, no691// scroll to reset -- because otherwise the camera it just wrote would be692// readable only after the next unrelated request, and the island's whole693// point is that the two accounts of the arrangement diff now, not later.694func TestCameraReportIsQuiet(t *testing.T) {695 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))696 got := post(t, handler, "/session/view", url.Values{697 "volume": {"tunic"}, "world": {"overworld"},698 "x": {"120.5"}, "y": {"-40"}, "zoom": {"6.25"}, "rotation": {"0.5"},699 })700 if got.Code != http.StatusOK {701 t.Fatalf("a camera report answered %d, want 200", got.Code)702 }703 body := got.Body.String()704 if strings.Count(body, "<hx-partial") != 1 ||705 !strings.Contains(body, `target="#atlas-session-island"`) {706 t.Errorf("a camera report answered with more than the island:\n%s", body)707 }708 if !strings.Contains(body, `"zoom":6.25`) {709 t.Errorf("the island does not carry the camera just reported:\n%s", body)710 }711712 held, err := host.sessions.Load("volume.tunic.json")713 if err != nil {714 t.Fatal(err)715 }716 var session app.Session717 if err := json.Unmarshal(held, &session); err != nil {718 t.Fatal(err)719 }720 camera, kept := session.Cameras["overworld"]721 if !kept || camera.X != 120.5 || camera.Zoom != 6.25 {722 t.Errorf("camera = %+v, kept = %v", camera, kept)723 }724 if camera.At == "" {725 t.Error("the camera report carries no time")726 }727}728729// A pick off the canvas, end to end: the page's half and the handler's.730//731// The seam resolves a hit and can do nothing with it on its own -- it fills a732// form this page renders and says so, exactly as the camera does. So the733// first half of this is that the form is there and wired, which is the whole734// of the defect it was written for: both panes reported picks for a milestone735// and nothing was listening, so clicking a feature moved nothing.736//737// The second half is the field the form deliberately does not carry. `focus`738// is what a row reached for from a list means -- take me there, and mark it739// in the index while I stand on it -- and a pick is not that: the reader who740// clicked the ground was already on it. Selecting and going are two facts,741// and only one of them is a canvas click's to state.742func TestCanvasPickSelectsWithoutFocusing(t *testing.T) {743 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))744745 page := get(t, handler, "/v/tunic/overworld", nil)746 if page.Code != http.StatusOK {747 t.Fatalf("the explorer answered %d", page.Code)748 }749 shell := page.Body.String()750 opens := strings.Index(shell, `<form hidden id="atlas-pick"`)751 if opens < 0 {752 t.Fatalf("the page renders no pick form, so a canvas pick posts nothing:\n%s", shell)753 }754 closes := strings.Index(shell[opens:], "</form>")755 if closes < 0 {756 t.Fatalf("the pick form is never closed:\n%s", shell[opens:])757 }758 pickForm := shell[opens : opens+closes]759 for _, want := range []string{760 `hx-post="/session/select"`,761 `hx-trigger="atlas:pick from:window"`,762 `name="feature" id="atlas-pick-feature"`,763 } {764 if !strings.Contains(pickForm, want) {765 t.Errorf("the pick form is missing %s:\n%s", want, pickForm)766 }767 }768 // The volume is inherited from the shell like every other interaction's,769 // and the focus flag is not here at all.770 if strings.Contains(pickForm, "focus") {771 t.Errorf("the pick form posts a focus field, so a pick would jump:\n%s", pickForm)772 }773774 got := post(t, handler, "/session/select", url.Values{775 "volume": {"tunic"}, "feature": {"1849"},776 })777 if got.Code != http.StatusOK {778 t.Fatalf("a canvas pick answered %d: %s", got.Code, got.Body)779 }780 if body := got.Body.String(); !strings.Contains(body, `target="#atlas-detail"`) {781 t.Errorf("a pick answered without the card it opened:\n%s", body)782 }783784 held, err := host.sessions.Load("volume.tunic.json")785 if err != nil {786 t.Fatalf("a pick wrote no session record: %v", err)787 }788 var session app.Session789 if err := json.Unmarshal(held, &session); err != nil {790 t.Fatal(err)791 }792 if session.Selected != "1849" || !session.Detail.Open {793 t.Errorf("selection = %q, detail = %+v", session.Selected, session.Detail)794 }795 if session.Focused != "" {796 t.Errorf("a canvas pick moved the index mark to %q; only a list row does that",797 session.Focused)798 }799}800801// A cell chosen off a surface, end to end: the page's half and the handler's.802//803// It is the pick's arrangement exactly, for the other thing a surface can be804// pointed at. A reader telescopes three ways -- typing an address, clicking a805// cell the chart drew, pressing a point on the sphere -- and only the first806// has a control of its own. The other two are pixels, and a pixel is the807// seam's to resolve; what it resolves to has to land somewhere, and this form808// is where.809//810// The field it does NOT carry is the interesting half. Which system is811// dividing the map is already in the record -- the address was resolved by812// that system -- so a seam posting one alongside would be the seam holding an813// opinion about state it does not own, and a stale one at that.814func TestGridPickFormCarriesTheAddressAndNothingElse(t *testing.T) {815 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))816817 page := get(t, handler, "/v/tunic/overworld", nil)818 if page.Code != http.StatusOK {819 t.Fatalf("the explorer answered %d", page.Code)820 }821 shell := page.Body.String()822 opens := strings.Index(shell, `<form hidden id="atlas-grid-pick"`)823 if opens < 0 {824 t.Fatalf("the page renders no grid-pick form, so a cell clicked posts nothing:\n%s", shell)825 }826 closes := strings.Index(shell[opens:], "</form>")827 if closes < 0 {828 t.Fatalf("the grid-pick form is never closed:\n%s", shell[opens:])829 }830 pickForm := shell[opens : opens+closes]831 for _, want := range []string{832 `hx-post="/session/grid"`,833 `hx-trigger="atlas:grid-pick from:window"`,834 `name="cell" id="atlas-grid-pick-cell"`,835 } {836 if !strings.Contains(pickForm, want) {837 t.Errorf("the grid-pick form is missing %s:\n%s", want, pickForm)838 }839 }840 // The address is the one field. The volume is inherited from the shell,841 // like every other interaction's on this page.842 if strings.Contains(pickForm, `name="system"`) {843 t.Errorf("the grid-pick form posts a system, which is already in the record:\n%s", pickForm)844 }845 if strings.Contains(pickForm, `name="volume"`) {846 t.Errorf("the grid-pick form posts its own volume rather than inheriting one:\n%s", pickForm)847 }848 // It posts to the route the navigator's own field posts to, with the same849 // field name, so one address is normalized and validated for all three850 // ways into a cell.851 if !strings.Contains(shell, `id="grid-input" name="cell"`) {852 t.Errorf("the navigator's field and the grid-pick form no longer name one field:\n%s", shell)853 }854 // And it posts every keystroke, with no memory of the last one. `changed`855 // remembers the value an event carried and refuses one that matches, which856 // is right for a field nobody else writes to and wrong for this one: every857 // swap re-renders it, so ascending out of "m" leaves an empty field beside858 // a remembered "m", and a reader typing that hash again is answered with859 // silence. `input` already fires only when the value moved.860 if !strings.Contains(shell, `hx-post="/session/grid" hx-trigger="input delay:150ms"`) {861 t.Errorf("the navigator's field does not post an ordinary debounced input:\n%s", shell)862 }863864 got := post(t, handler, "/session/grid", url.Values{865 "volume": {"tunic"}, "cell": {"9q"},866 })867 if got.Code != http.StatusOK {868 t.Fatalf("a cell chosen off a surface answered %d: %s", got.Code, got.Body)869 }870871 held, err := host.sessions.Load("volume.tunic.json")872 if err != nil {873 t.Fatalf("a cell wrote no session record: %v", err)874 }875 var session app.Session876 if err := json.Unmarshal(held, &session); err != nil {877 t.Fatal(err)878 }879 if session.Grid.Cell != "9q" {880 t.Errorf("cell = %q, want the one the surface named", session.Grid.Cell)881 }882}883884func TestSessionRefusals(t *testing.T) {885 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))886 cases := []struct {887 name string888 path string889 form url.Values890 want int891 }{892 {"a concern nobody declared", "/session/telepathy", url.Values{"volume": {"tunic"}}, http.StatusNotFound},893 {"no volume named", "/session/dock", url.Values{}, http.StatusBadRequest},894 {"a volume that is not installed", "/session/dock", url.Values{"volume": {"mars"}}, http.StatusNotFound},895 {"a world that is not a slug", "/session/world", url.Values{"volume": {"tunic"}, "world": {"../etc"}}, http.StatusBadRequest},896 {"a camera report with no camera", "/session/view", url.Values{"volume": {"tunic"}, "world": {"overworld"}}, http.StatusBadRequest},897 }898 for _, tt := range cases {899 t.Run(tt.name, func(t *testing.T) {900 if got := post(t, handler, tt.path, tt.form); got.Code != tt.want {901 t.Errorf("%s answered %d, want %d", tt.path, got.Code, tt.want)902 }903 })904 }905}906907// The keyboard the shell declares, read back out of the page.908//909// These are string assertions on markup, which is usually a smell, and here it910// is the whole point: an hx-trigger filter is executable code the server only911// ever writes and never runs, so the one place a missing guard can be caught912// before a reader finds it is the bytes. Each shortcut is checked for the913// three things every one of them has to say -- whose keystroke it is, that the914// key is answered, and that a held key is not a hundred keys -- and for the915// two exceptions that are deliberate.916func TestKeyboardShortcutsAreHardened(t *testing.T) {917 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))918 page := get(t, handler, "/v/tunic/overworld", nil)919 if page.Code != http.StatusOK {920 t.Fatalf("the explorer answered %d", page.Code)921 }922 shell := page.Body.String()923 opens := strings.Index(shell, `<div hidden id="atlas-shortcuts">`)924 if opens < 0 {925 t.Fatalf("the page declares no shortcuts:\n%s", shell)926 }927 closes := strings.Index(shell[opens:], "</div>")928 if closes < 0 {929 t.Fatalf("the shortcuts block is never closed:\n%s", shell[opens:])930 }931 block := shell[opens : opens+closes]932933 // The editable-target guard, spelled the one way every filter spells it. A934 // select is in the list because a select answers keys itself, and the935 // `instanceof Element` is what lets a key dispatched straight at the936 // window -- a headless driver's way of pressing one -- through as nobody's937 // typing.938 const guard = `!(event.target instanceof Element&&` +939 `(/^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)||event.target.isContentEditable))`940 if !strings.Contains(block, guard) {941 t.Fatalf("no filter carries the editable-target guard:\n%s", block)942 }943944 for _, want := range []struct {945 name string946 filter string947 guard bool948 }{949 // โG is one of the two shortcuts deliberately above the guard:950 // cycling the cell system is most wanted from inside the token field.951 {"โG cycles the cell system",952 `keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='g'&&!event.repeat] from:window prevent`, false},953 // โR is the other, and for the reference's own reason: a reader whose954 // page is misbehaving reaches for reload from wherever the cursor is,955 // and a reload that answers everywhere except the field they happen to956 // be typing in is a reload half taken away.957 {"โR resets this volume's session",958 `keydown[(metaKey||ctrlKey)&&key.toLowerCase()=='r'&&!event.repeat] from:window prevent`, false},959 // โโฅB by physical key, because Option rewrites event.key on a Mac. Two960 // spans, each asking the fold button which way it is pointing, so961 // neither can go stale while the shell is not re-rendered with it.962 {"โโฅB unfolds the panel",963 `keydown[event.code=='KeyB'&&(metaKey||ctrlKey)&&altKey&&!event.repeat&&` + guard +964 `&&document.querySelector('#dock-fold')?.getAttribute('aria-expanded')=='false'] from:window prevent`, true},965 {"โโฅB folds the panel",966 `keydown[event.code=='KeyB'&&(metaKey||ctrlKey)&&altKey&&!event.repeat&&` + guard +967 `&&document.querySelector('#dock-fold')?.getAttribute('aria-expanded')=='true'] from:window prevent`, true},968 // โB refuses altKey, so the two B chords can never both answer.969 {"โB puts the index away",970 `keydown[(metaKey||ctrlKey)&&!altKey&&key.toLowerCase()=='b'&&!event.repeat&&` + guard +971 `] from:window prevent`, true},972 {"G opens the grid",973 `keydown[key.toLowerCase()=='g'&&!metaKey&&!ctrlKey&&!altKey&&!event.repeat&&` + guard +974 `] from:window prevent`, true},975 {"Space divides the chosen cell",976 `keydown[key==' '&&!event.repeat&&` + guard +977 `&&document.querySelector('#atlas-grid-navigator')?.hidden===false` +978 `&&event.target!==document.querySelector('#subgrid-toggle')] from:window prevent`, true},979 // The field's own space bar, which the guard above shuts out by980 // design: it is heard on the field, so it needs no guard of its own.981 {"Space divides it from the field too",982 `keydown[key==' '&&!event.repeat] from:#grid-input prevent`, false},983 {"Escape closes the card",984 `keydown[key=='Escape'&&!event.repeat&&` + guard + `] from:#map prevent`, true},985 {"Escape telescopes the grid out",986 `keydown[key=='Escape'&&!event.repeat&&` + guard +987 `&&document.querySelector('#atlas-grid-navigator')?.hidden===false] from:window prevent`, true},988 } {989 if !strings.Contains(block, `hx-trigger="`+want.filter+`"`) {990 t.Errorf("%s is missing or unhardened; wanted the trigger\n %s\nin\n%s",991 want.name, want.filter, block)992 continue993 }994 if want.guard && !strings.Contains(want.filter, guard) {995 t.Errorf("%s does not carry the editable-target guard", want.name)996 }997 }998999 // The routes the shortcuts stand on, and the fold's two directions.1000 for _, want := range []string{1001 `hx-post="/session/grid" hx-vals:append='{"system":"cycle"}'`,1002 `hx-post="/session/grid" hx-vals:append='{"system":"toggle"}'`,1003 `hx-post="/session/grid" hx-vals:append='{"subgrid":"flip"}'`,1004 `hx-post="/session/grid" hx-vals:append='{"ascend":"1"}'`,1005 `hx-post="/session/dock" hx-vals:append='{"open":"1","byHand":"1"}'`,1006 `hx-post="/session/dock" hx-vals:append='{"open":"0","byHand":"1"}'`,1007 `hx-post="/session/select" hx-vals:append='{"feature":""}'`,1008 `hx-post="/session/sidebar"`,1009 `hx-post="/session/reset"`,1010 } {1011 if !strings.Contains(block, want) {1012 t.Errorf("the shortcuts block posts no %s:\n%s", want, block)1013 }1014 }10151016 // Escape means two things at once and both happen, so the two requests it1017 // raises are queued rather than raced: the card closes, and then the grid1018 // ascends against a record the first answer has already written.1019 if got := strings.Count(block, `hx-sync="#atlas-shell:queue all"`); got != 2 {1020 t.Errorf("%d of Escape's two routes queue on the shell; a press would race itself:\n%s",1021 got, block)1022 }10231024 // โR takes the browser's reload and hands it back. The keystroke the page1025 // swallows is answered with the volume's own address again, over a record1026 // that has just been deleted -- so the shortcut has to stand above the1027 // editable guard, where the reference put it, and the span that claims it1028 // must carry no guard at all.1029 reset := strings.Index(block, `hx-post="/session/reset"`)1030 if reset < 0 {1031 t.Fatalf("nothing claims โR; the blunt reset has no key:\n%s", block)1032 }1033 span := block[reset:]1034 if closes := strings.Index(span, "</span>"); closes >= 0 {1035 span = span[:closes]1036 }1037 if strings.Contains(span, guard) {1038 t.Errorf("โR carries the editable-target guard, so a page a reader is "+1039 "typing in cannot be reset:\n%s", span)1040 }1041 if !strings.Contains(span, ` prevent"`) {1042 t.Errorf("โR does not swallow the browser's own reload, which would "+1043 "race the redirect:\n%s", span)1044 }10451046 // Every filter says the key was pressed rather than held. A key held down1047 // on a route that writes a file is sixty writes a second.1048 spans := strings.Count(block, "hx-trigger=")1049 if repeats := strings.Count(block, "!event.repeat"); repeats != spans {1050 t.Errorf("%d of %d shortcuts guard the autorepeat", repeats, spans)1051 }1052 // And every one of them answers the key it acts on, which is what stops1053 // the machine sounding its rejection tone at every press.1054 if prevented := strings.Count(block, ` prevent"`); prevented != spans {1055 t.Errorf("%d of %d shortcuts swallow the keystroke they answer", prevented, spans)1056 }1057 // The seam's half is not here, and must not be: none of it moves discrete1058 // state, and one of them cannot be a request at all.1059 for _, absent := range []string{"'z'", "'`'", "'k'", "contextmenu"} {1060 if strings.Contains(block, absent) {1061 t.Errorf("the shell declares %s, which belongs to the seam:\n%s", absent, block)1062 }1063 }1064}10651066func TestDetailFragment(t *testing.T) {1067 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1068 got := get(t, handler, "/fragments/detail/1849?volume=tunic", nil)1069 if got.Code != http.StatusOK {1070 t.Fatalf("the detail fragment answered %d", got.Code)1071 }1072 // What the fragment must always carry is its own container, because the1073 // region is swapped into it by id.1074 if !strings.Contains(got.Body.String(), `id="atlas-detail"`) {1075 t.Errorf("the fragment carries no card container:\n%s", got.Body)1076 }1077 // This volume's payload holds no features, so nothing resolves and the1078 // card comes back closed -- which it says with the `hidden` the carried1079 // assets/css/pin-detail.css reads, not by being empty. The states a reader1080 // walks through are held in tests/island; this is the one the fragment1081 // route reaches on its own.1082 if !strings.Contains(got.Body.String(), `class="pin-detail" hidden>`) {1083 t.Errorf("a card with nothing to show does not say it is closed:\n%s", got.Body)1084 }1085 if got := get(t, handler, "/fragments/detail/1849", nil); got.Code != http.StatusBadRequest {1086 t.Errorf("a fragment naming no volume answered %d", got.Code)1087 }1088}10891090// zonedVolume is a volume whose world is two shape collections with named1091// features, which is what a feature index needs to have rows: a shape is1092// indexed only once it has ground to draw (internal/app/world.go, Drawn).1093func zonedVolume() *fakeVolume {1094 held := volume("tunic", "TUNIC", tunicStamp)1095 ring := func(west, east string) string {1096 return `[{"type":"Polygon","coordinates":[[[` + west + `,44.00],[` + east +1097 `,44.00],[` + east + `,44.05],[` + west + `,44.05],[` + west + `,44.00]]]}]`1098 }1099 held.entries["worlds/overworld.json"] = []byte(`{"lenses":[],"collections":[1100 {"id":900,"title":"Zoning","kind":"area","attrs":{"atlas.geometry.kind":"area"},"features":[1101 {"id":91,"title":"R1","geometry":` + ring("-121.30", "-121.25") + `},1102 {"id":92,"title":"R2","geometry":` + ring("-121.20", "-121.15") + `},1103 {"id":93,"title":"R3","geometry":` + ring("-121.10", "-121.05") + `}]},1104 {"id":901,"title":"Watersheds","kind":"area","attrs":{"atlas.geometry.kind":"area"},"features":[1105 {"id":94,"title":"Tumalo","geometry":` + ring("-121.00", "-120.95") + `}]}1106 ]}`)1107 return held1108}11091110// zoneOnlyButton is one zone row's exclusive control, from the attribute that1111// names the zone to the end of the button that carries it.1112func zoneOnlyButton(page, zone string) string {1113 at := strings.Index(page, `data-zone-only="`+zone+`"`)1114 if at < 0 {1115 return ""1116 }1117 rest := page[at:]1118 if end := strings.Index(rest, "</button>"); end >= 0 {1119 return rest[:end]1120 }1121 return rest1122}11231124// The exclusive control on a zone row: this ground, and no other.1125//1126// It is this application's own addition rather than a port. The reference implementation1127// gave the only-button to collections and to sections alone, and its zone1128// highlights only ever accumulated (frontend/src/areas.js, toggleZoneHighlight)1129// -- so a reader who wanted one zone had to clear the set by hand, or isolate1130// the collection, which answers the different and heavier question of putting1131// every other collection away.1132//1133// What this holds is the whole of the move: an accumulated set is replaced1134// rather than added to, the press that made a zone exclusive is the press that1135// gives the highlights back, and a highlight still brings its own collection1136// out of hiding on the way -- asking to look at a piece of ground and keeping1137// it put away cannot both be meant, however the asking was spelled.1138func TestOneZoneCanBeAskedForExclusively(t *testing.T) {1139 handler, host := newApp(t, zonedVolume())1140 session := func(t *testing.T) app.Session {1141 t.Helper()1142 held, err := host.sessions.Load("volume.tunic.json")1143 if err != nil {1144 t.Fatalf("no session was written: %v", err)1145 }1146 var out app.Session1147 if err := json.Unmarshal(held, &out); err != nil {1148 t.Fatal(err)1149 }1150 return out1151 }1152 highlight := func(t *testing.T, form url.Values) *httptest.ResponseRecorder {1153 t.Helper()1154 form["volume"] = []string{"tunic"}1155 got := post(t, handler, "/session/highlight", form)1156 if got.Code != http.StatusOK {1157 t.Fatalf("/session/highlight answered %d: %s", got.Code, got.Body)1158 }1159 return got1160 }11611162 // The accumulating form, which is what the row's right button asks and1163 // what this control is not: two zones highlighted is two zones.1164 highlight(t, url.Values{"feature": {"91"}})1165 highlight(t, url.Values{"feature": {"92"}})1166 if held := session(t).Highlighted; len(held) != 2 {1167 t.Fatalf("highlighting twice did not accumulate: %v", held)1168 }1169 // The collection the exclusive zone belongs to is away, so the ride-along1170 // has something to do.1171 if got := post(t, handler, "/session/collections",1172 url.Values{"volume": {"tunic"}, "collection": {"901"}, "visible": {"0"}}); got.Code != http.StatusOK {1173 t.Fatalf("hiding a collection answered %d", got.Code)1174 }11751176 // The exclusive form replaces the set rather than joining it.1177 answer := highlight(t, url.Values{"feature": {"94"}, "only": {"1"}})1178 held := session(t)1179 if len(held.Highlighted) != 1 || held.Highlighted[0] != "94" {1180 t.Errorf("the exclusive press did not replace the set: %v", held.Highlighted)1181 }1182 if contains(held.Hidden, "901") {1183 t.Errorf("the exclusive press left its own collection hidden: %v", held.Hidden)1184 }1185 // It moves what a highlight moves, and nothing else: the same three1186 // regions the accumulating form declares (docs/app.md ยง4.3).1187 for _, target := range []string{"#atlas-legend", "#atlas-dock", "#atlas-viewport-state"} {1188 if !strings.Contains(answer.Body.String(), `target="`+target+`"`) {1189 t.Errorf("the answer carries no partial for %s", target)1190 }1191 }11921193 // Pressing it again on the zone that is already alone is the way out. It1194 // is the isolate chip's own toggle, one row further in: a control that set1195 // a filter is the control that lifts it.1196 highlight(t, url.Values{"feature": {"94"}, "only": {"1"}})1197 if held := session(t).Highlighted; len(held) != 0 {1198 t.Errorf("pressing the exclusive control again did not clear the highlights: %v", held)1199 }12001201 // And a zone that is alone by another route -- highlighted one at a time1202 // until one was left -- is exclusive too, because the state is derived1203 // from the set rather than from which button reached it.1204 highlight(t, url.Values{"feature": {"93"}})1205 highlight(t, url.Values{"feature": {"93"}, "only": {"1"}})1206 if held := session(t).Highlighted; len(held) != 0 {1207 t.Errorf("a lone highlight was not treated as an exclusive one: %v", held)1208 }1209}12101211// The control on the page: every zone row wears one, it says what it would do,1212// and the zone that is the whole of the highlight is the one that reads as1213// pressed.1214func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {1215 handler, _ := newApp(t, zonedVolume())1216 if got := post(t, handler, "/session/highlight",1217 url.Values{"volume": {"tunic"}, "feature": {"93"}, "only": {"1"}}); got.Code != http.StatusOK {1218 t.Fatalf("/session/highlight answered %d: %s", got.Code, got.Body)1219 }1220 page := get(t, handler, "/v/tunic/overworld", nil)1221 if page.Code != http.StatusOK {1222 t.Fatalf("the explorer answered %d", page.Code)1223 }1224 shell := page.Body.String()12251226 // One control per zone, and the request it carries is the exclusive form1227 // of the highlight concern -- not the isolate route the collection rows1228 // use, which would put every other collection away.1229 for _, zone := range []string{"91", "92", "93", "94"} {1230 markup := zoneOnlyButton(shell, zone)1231 if markup == "" {1232 t.Fatalf("the zone %s has no exclusive control:\n%s", zone, legendOf(t, shell))1233 }1234 if !strings.Contains(markup, `hx-post="/session/highlight"`) {1235 t.Errorf("the zone %s control does not ask the highlight concern:\n%s", zone, markup)1236 }1237 if !strings.Contains(markup, `"only":"1"`) {1238 t.Errorf("the zone %s control does not ask exclusively:\n%s", zone, markup)1239 }1240 if !strings.Contains(markup, `"feature":"`+zone+`"`) {1241 t.Errorf("the zone %s control names another feature:\n%s", zone, markup)1242 }1243 }1244 // It is the same control the collection rows wear, so it reads and draws1245 // as one: the carried `.only-button` rule is what reveals it.1246 if !strings.Contains(zoneOnlyButton(shell, "91"), `aria-label="Exclusively R1"`) {1247 t.Errorf("the control does not say what it does:\n%s", zoneOnlyButton(shell, "91"))1248 }1249 // And the pressed state is the state, not the press: R3 is the whole of1250 // what is highlighted, so its control is the one that reads pressed.1251 if !strings.Contains(zoneOnlyButton(shell, "93"), `aria-pressed="true"`) {1252 t.Errorf("the exclusive zone's control does not read pressed:\n%s", zoneOnlyButton(shell, "93"))1253 }1254 for _, zone := range []string{"91", "92", "94"} {1255 if !strings.Contains(zoneOnlyButton(shell, zone), `aria-pressed="false"`) {1256 t.Errorf("the zone %s reads exclusive while %s is:\n%s", zone, "93", zoneOnlyButton(shell, zone))1257 }1258 }1259 // The control is a sibling of the row's own button and not a child of it,1260 // which is the whole of how one click cannot be the other: an event1261 // reaches its ancestors and never its siblings.1262 row := shell[strings.Index(shell, `data-zone="93"`):]1263 row = row[:strings.Index(row, `data-zone-only="93"`)]1264 if strings.Count(row, "</button>") != 1 {1265 t.Errorf("the exclusive control is nested inside the row's own button:\n%s", row)1266 }1267}12681269// A legend row wears the collection's artwork, and only a collection with no1270// artwork wears its initials.1271//1272// The reference set `--pin-icon` and the `has-source-icon` class on the row's1273// icon cell and emptied its text, or wrote the initials into it1274// (frontend/src/theme.js, applyCategoryGlyph); the stylesheet that draws one1275// from the other came over verbatim, so the whole of the port is what the1276// template emits. A city is the volume that showed it missing -- its1277// collections are enriched rather than curated, so they carry a standard glyph1278// and no colour of their own -- and it is the shape the payload below has.1279func TestALegendRowWearsTheCollectionsArtwork(t *testing.T) {1280 held := volume("bend-or", "Bend, Oregon", tunicStamp)1281 held.entries["worlds/overworld.json"] = []byte(`{"lenses":[],"collections":[1282 {"id":1496244488,"title":"Historic Resources","kind":"point","group":"Heritage",1283 "icon":"historic-resources","iconAsset":"std--maki-monument.svg",1284 "attrs":{"atlas.geometry.kind":"point","atlas.icon.kind":"glyph"}},1285 {"id":42,"title":"Fire Stations","kind":"point","group":"Heritage",1286 "attrs":{"atlas.geometry.kind":"point"}}1287 ]}`)1288 handler, _ := newApp(t, held)12891290 page := get(t, handler, "/v/bend-or/overworld", nil)1291 if page.Code != http.StatusOK {1292 t.Fatalf("the explorer answered %d", page.Code)1293 }1294 shell := page.Body.String()1295 base := "/data/v/bend-or/" + bundle.ShortStamp(tunicStamp)12961297 // The collection that carries a glyph: the artwork, named the way the1298 // seam names it, and no initials to draw over it.1299 wearing := `<span class="category-icon has-source-icon" style="--pin-icon: ` +1300 `url('` + base + `/icons/std--maki-monument.svg')" title="historic-resources"></span>`1301 if !strings.Contains(shell, wearing) {1302 t.Errorf("the Historic Resources row draws no artwork; want\n\t%s\nin\n%s", wearing, legendOf(t, shell))1303 }1304 // The collection that carries none: its initials, and nothing that would1305 // make the stylesheet look for a picture.1306 if want := `<span class="category-icon" title="Fire Stations">FS</span>`; !strings.Contains(shell, want) {1307 t.Errorf("a collection with no artwork lost its initials; want\n\t%s\nin\n%s", want, legendOf(t, shell))1308 }13091310 // And the colour: first in the payload is first on the wheel, which is1311 // the colour the seam draws the same collection's pins in1312 // (render/chart/styles.ts, collectionColor). The two used to disagree1313 // -- the legend hashed the collection's id -- and a city, whose1314 // collections declare no colour of their own, is where it showed.1315 if want := `class="category-row" style="--pin-color: #4fb3d5"`; !strings.Contains(shell, want) {1316 t.Errorf("the first collection is not wearing the first colour of the wheel:\n%s", legendOf(t, shell))1317 }1318}13191320// legendOf is the sidebar alone, for a failure message that is readable.1321func legendOf(t *testing.T, shell string) string {1322 t.Helper()1323 opens := strings.Index(shell, `<aside id="atlas-legend"`)1324 if opens < 0 {1325 return shell1326 }1327 closes := strings.Index(shell[opens:], "</aside>")1328 if closes < 0 {1329 return shell[opens:]1330 }1331 return shell[opens : opens+closes]1332}13331334// contains is the membership question a handful of checks above ask of a1335// sorted set.1336func contains(set []string, member string) bool {1337 for _, held := range set {1338 if held == member {1339 return true1340 }1341 }1342 return false1343}13441345func TestStaticWithoutASeam(t *testing.T) {1346 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1347 if got := get(t, handler, "/static/app.js", nil); got.Code != http.StatusNotFound {1348 t.Errorf("a host that mounted no seam answered %d, want 404: the application works without it",1349 got.Code)1350 }1351}13521353// The one thing a reset keeps is the reader's place. A record that named a1354// lens comes back as a record naming only the world and that lens -- the1355// arrangement, the filters, the cameras and the rest go, and the next load1356// arranges a fresh record around the place the reader was already standing.1357func TestSessionResetKeepsTheWorldAndTheLens(t *testing.T) {1358 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))1359 if page := get(t, handler, "/v/tunic/overworld", nil); page.Code != http.StatusOK {1360 t.Fatalf("the explorer answered %d", page.Code)1361 }1362 for _, step := range []struct {1363 concern string1364 form url.Values1365 }{1366 {"lens", url.Values{"lens": {"second-look"}}},1367 {"collections", url.Values{"collection": {"7"}, "visible": {"0"}}},1368 {"highlight", url.Values{"feature": {"1849"}}},1369 } {1370 form := url.Values{"volume": {"tunic"}}1371 for name, values := range step.form {1372 form[name] = values1373 }1374 if got := post(t, handler, "/session/"+step.concern, form); got.Code != http.StatusOK {1375 t.Fatalf("/session/%s answered %d: %s", step.concern, got.Code, got.Body)1376 }1377 }13781379 if got := post(t, handler, "/session/reset", url.Values{"volume": {"tunic"}}); got.Code != http.StatusNoContent {1380 t.Fatalf("the reset answered %d: %s", got.Code, got.Body)1381 }13821383 after := sessionRecord(t, host, "volume.tunic.json")1384 if after.World != "overworld" || after.Lens != "second-look" {1385 t.Errorf("the reset lost the reader's place: world %q lens %q", after.World, after.Lens)1386 }1387 if len(after.Hidden) != 0 || len(after.Highlighted) != 0 || after.Arranged ||1388 after.Search != "" || len(after.Cameras) != 0 || after.Selected != "" {1389 t.Errorf("the reset kept more than the place: %+v", after)1390 }1391}13921393// The retired HTTP transcript held the catalog byte for byte; what those bytes1394// were saying is held here instead. A client plans its whole session off this1395// one document -- the stamp is how it knows a build moved, the tile grid is1396// how it addresses the pyramid before any payload has arrived, and the world's1397// counts and updatedAt are what the library card prints -- so each is checked1398// as the manifest's own value carried through, not as a recorded string.1399func TestCatalogCarriesTheBuildAndItsGrid(t *testing.T) {1400 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1401 got := get(t, handler, "/data/catalog.json", nil)1402 if got.Code != http.StatusOK {1403 t.Fatalf("catalog answered %d", got.Code)1404 }1405 var catalog struct {1406 Volumes []struct {1407 Stamp string `json:"stamp"`1408 TileGrid bundle.TileGrid `json:"tileGrid"`1409 Worlds []struct {1410 Title string `json:"title"`1411 Points int `json:"points"`1412 Paths int `json:"paths"`1413 Areas int `json:"areas"`1414 UpdatedAt string `json:"updatedAt"`1415 } `json:"worlds"`1416 } `json:"volumes"`1417 }1418 if err := json.Unmarshal(got.Body.Bytes(), &catalog); err != nil {1419 t.Fatal(err)1420 }1421 if len(catalog.Volumes) != 1 {1422 t.Fatalf("catalog lists %d volumes", len(catalog.Volumes))1423 }1424 held := catalog.Volumes[0]1425 // The whole stamp, not the twelve characters the base URL carries: the1426 // short form names a directory and the long form names the build.1427 if held.Stamp != tunicStamp {1428 t.Errorf("stamp = %q, want the manifest's own", held.Stamp)1429 }1430 if want := (bundle.TileGrid{SourceZoom: 13, FirstTile: 4064, TileSize: 256, Size: 8192}); held.TileGrid != want {1431 t.Errorf("tileGrid = %+v, want the manifest's %+v", held.TileGrid, want)1432 }1433 if len(held.Worlds) != 1 {1434 t.Fatalf("the volume lists %d worlds", len(held.Worlds))1435 }1436 world := held.Worlds[0]1437 if world.Title != "Overworld" || world.Points != 3 || world.Paths != 0 || world.Areas != 0 {1438 t.Errorf("the world's card reads %+v, not the manifest's counts", world)1439 }1440 if world.UpdatedAt != "2026-01-01T00:00:00Z" {1441 t.Errorf("updatedAt = %q, want the manifest's own", world.UpdatedAt)1442 }1443}14441445// A climb out of the volume. The retired transcript recorded this exchange1446// through an ordinary client, so the 404 it holds sits at the end of the1447// redirect the router's own path cleaning answers first; the recorder here1448// sees the walk one hop at a time, and reproduces it: every hop stays on this1449// application, and the end of it is a refusal, never a file.1450func TestDataPlaneRefusesAClimbOutOfTheVolume(t *testing.T) {1451 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1452 path := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp) + "/worlds/../../../../etc/passwd"14531454 got := get(t, handler, path, nil)1455 for hop := 0; got.Code >= 300 && got.Code < 400; hop++ {1456 if hop == 5 {1457 t.Fatal("the climb is still being redirected after five hops")1458 }1459 where := got.Header().Get("Location")1460 if !strings.HasPrefix(where, "/") {1461 t.Fatalf("the climb was redirected off the application, to %q", where)1462 }1463 got = get(t, handler, where, nil)1464 }1465 if got.Code != http.StatusNotFound {1466 t.Errorf("the climb ended at %d, want 404: %s", got.Code, got.Body)1467 }1468}14691470// The redirect is really sent. The desktop host cannot show a redirect to its1471// webview, so it follows the handler's redirects itself (redirects.go) -- and1472// it only follows a Location that names a path on this application. Both1473// halves of that arrangement are the handler's to honor: / answers with an1474// actual 302 rather than the followed page, and the doorway it names is a1475// path, not an absolute URL that would belong to somebody else's document.1476func TestTheDoorwayIsARealRedirectToAPath(t *testing.T) {1477 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))1478 got := get(t, handler, "/", nil)1479 if got.Code != http.StatusFound {1480 t.Fatalf("/ answered %d, want the 302 that is really sent", got.Code)1481 }1482 where := got.Header().Get("Location")1483 if !strings.HasPrefix(where, "/") || strings.HasPrefix(where, "//") {1484 t.Errorf("Location = %q, want a path on this application", where)1485 }1486 // The body is Go's own two words, not the page at the end of the doorway:1487 // a handler that pre-followed would be a second copy of the world page1488 // nobody asked for, painted over the one the client fetches next.1489 if body := got.Body.String(); strings.Contains(body, "<html") || strings.Contains(body, "atlas-shell") {1490 t.Errorf("the doorway answered with a whole page instead of a redirect:\n%s", body)1491 }1492}