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 / tests / island / island_test.go
island_test.go 17.0K
1package island23import (4 "net/http"5 "net/url"6 "reflect"7 "sort"8 "strconv"9 "strings"10 "testing"11)1213// The state island, held to its own contract.14//15// internal/app/island.go promises twelve keys -- the ones the reference16// implementation wrote to localStorage -- and promises what each one is: the17// volume and world the page stands in, the lens as an index, the camera as18// the server's echo of what the seam last reported, the hide set as the19// payload's own ids, the fold and label ledgers, and the three booleans of20// the furniture. The recorded baselines these used to be compared against are21// gone; what replaced the recording is the contract itself, asked over the22// two corpus volumes: every key present, no key invented, and every value in23// agreement with the page it rides in and the corpus payload the page was24// rendered from.2526// islandKeys is the whole of what the entry may say. A key missing is a hole27// in the contract; a key beyond these is an invention no client asked for.28var islandKeys = []string{29 "volume", "world", "lens", "center", "zoom", "hidden", "collapsed",30 "expanded", "labels", "overviewDocked", "dockFolded", "dockDismissed",31}3233// holdKeys holds an entry to exactly the twelve.34func holdKeys(t *testing.T, entry map[string]any) {35 t.Helper()36 for _, key := range islandKeys {37 if _, held := entry[key]; !held {38 t.Errorf("the island has no %q, which the contract promises", key)39 }40 }41 for key := range entry {42 found := false43 for _, want := range islandKeys {44 if key == want {45 found = true46 break47 }48 }49 if !found {50 t.Errorf("the island carries %q, which the contract does not name", key)51 }52 }53}5455// idStrings flattens a JSON id list -- numbers on the wire, float64 here --56// back to the strings they ride the DOM as, sorted the way the island sorts.57func idStrings(t *testing.T, value any) []string {58 t.Helper()59 held, ok := value.([]any)60 if !ok {61 t.Fatalf("not a list: %v", value)62 }63 out := make([]string, 0, len(held))64 for _, member := range held {65 switch v := member.(type) {66 case float64:67 out = append(out, strconv.FormatFloat(v, 'f', -1, 64))68 case string:69 out = append(out, v)70 default:71 t.Fatalf("an id that is neither number nor string: %v", member)72 }73 }74 return out75}7677// openExplorer opens one corpus volume's first world and hands back the page.78func openExplorer(t *testing.T, handler http.Handler, slug, world string) string {79 t.Helper()80 page := get(t, handler, "/v/"+slug+"/"+world)81 if page.Code != http.StatusOK {82 t.Fatalf("the explorer answered %d", page.Code)83 }84 return page.Body.String()85}8687// arrange runs one session interaction and requires it answered.88func arrange(t *testing.T, handler http.Handler, slug, concern string, pairs ...string) string {89 t.Helper()90 form := url.Values{"volume": {slug}}91 for i := 0; i+1 < len(pairs); i += 2 {92 form.Set(pairs[i], pairs[i+1])93 }94 answer := post(t, handler, "/session/"+concern, form)95 if answer.Code != http.StatusOK {96 t.Fatalf("/session/%s answered %d: %s", concern, answer.Code, answer.Body)97 }98 return answer.Body.String()99}100101// TestTheIslandOpensWithTheWorldsOwnArrangement holds the opening102// arrangement: a world nobody has arranged publishes exactly the twelve103// keys, and every value is the one the world itself supplies -- nothing104// hidden, folded or unfolded, the first lens, a camera nobody has reported,105// and the furniture in its opening positions.106func TestTheIslandOpensWithTheWorldsOwnArrangement(t *testing.T) {107 for _, slug := range []string{"bend-or", "mars"} {108 t.Run(slug, func(t *testing.T) {109 volume := corpusVolume(t, slug)110 world := firstWorld(t, volume)111 handler := newApp(t, volume)112113 page := openExplorer(t, handler, slug, world)114 island := readIsland(t, page)115 if island["last"] != slug {116 t.Errorf("island last = %v, want %s", island["last"], slug)117 }118 entry := entryOf(t, page)119 holdKeys(t, entry)120121 if entry["volume"] != slug {122 t.Errorf("volume = %v", entry["volume"])123 }124 if entry["world"] != world {125 t.Errorf("world = %v", entry["world"])126 }127 if lens, _ := entry["lens"].(float64); lens != 0 {128 t.Errorf("lens = %v, want the first lens's index", entry["lens"])129 }130 // The camera is the seam's to originate, and nobody has spoken.131 if entry["center"] != nil || entry["zoom"] != nil {132 t.Errorf("a world nobody has looked at carries a camera: center %v, zoom %v",133 entry["center"], entry["zoom"])134 }135 // Neither corpus payload curates anything invisible, so the hide136 // set opens empty -- and empty, not null: the session schema's137 // keys come with shapes as well as names (docs/app.md ยง6).138 if got := idStrings(t, entry["hidden"]); len(got) != 0 {139 t.Errorf("hidden opens as %v", got)140 }141 if got := idStrings(t, entry["collapsed"]); len(got) != 0 {142 t.Errorf("collapsed opens as %v", got)143 }144 if got := idStrings(t, entry["labels"]); len(got) != 0 {145 t.Errorf("the label ledger opens holding %v", got)146 }147 // Every feature index arrives folded: a category is a fact about148 // the world, its hundred members are a request the reader makes149 // (one press on the row, or one on unfold-all).150 if got := idStrings(t, entry["expanded"]); len(got) != 0 {151 t.Errorf("expanded opens as %v; every index arrives folded", got)152 }153 if entry["overviewDocked"] != false {154 t.Errorf("overviewDocked opens %v", entry["overviewDocked"])155 }156 if entry["dockFolded"] != true {157 t.Errorf("dockFolded opens %v; a map opens on the map", entry["dockFolded"])158 }159 if entry["dockDismissed"] != false {160 t.Errorf("dockDismissed opens %v", entry["dockDismissed"])161 }162 })163 }164}165166// TestTheHideSetIsThePayloadsOwnIDs walks the hide set through one row, the167// whole world, and back, and holds the island to the page at every step: the168// ids recorded are the payload's own, and the count the footer draws is the169// corpus's own arithmetic.170func TestTheHideSetIsThePayloadsOwnIDs(t *testing.T) {171 const slug = "bend-or"172 volume := corpusVolume(t, slug)173 world := firstWorld(t, volume)174 payload := readCorpusWorld(t, slug, world)175 handler := newApp(t, volume)176 openExplorer(t, handler, slug, world)177178 everything := pointTotal(t, slug, world) + shapeTotal(payload)179 countOf := func(n int) string {180 if n == 1 {181 return ">1 feature<"182 }183 return ">" + strconv.Itoa(n) + " features<"184 }185186 // One row put away: the id lands in the set, and the features it carried187 // leave the count.188 mpo := collectionNamed(t, payload, "MPO Boundary")189 answer := arrange(t, handler, slug, "collections",190 "collection", mpo.NativeID, "visible", "0")191 if !strings.Contains(answer, countOf(everything-len(mpo.Features))) {192 t.Errorf("hiding %s did not take its %d features off the count:\n%s",193 mpo.Title, len(mpo.Features), answer)194 }195 page := openExplorer(t, handler, slug, world)196 if got := idStrings(t, entryOf(t, page)["hidden"]); !reflect.DeepEqual(got, []string{mpo.NativeID}) {197 t.Errorf("hidden = %v, want the one row put away", got)198 }199200 // Everything put away: the set is every collection the payload declares,201 // sorted as the strings they ride the DOM as, and the map draws nothing.202 answer = arrange(t, handler, slug, "collections", "all", "hide")203 if !strings.Contains(answer, countOf(0)) {204 t.Errorf("hiding everything left something on the count:\n%s", answer)205 }206 want := make([]string, 0, len(payload.Collections))207 for _, collection := range payload.Collections {208 want = append(want, collection.NativeID)209 }210 sort.Strings(want)211 page = openExplorer(t, handler, slug, world)212 if got := idStrings(t, entryOf(t, page)["hidden"]); !reflect.DeepEqual(got, want) {213 t.Errorf("hidden = %v, want every collection the payload declares %v", got, want)214 }215216 // And everything back: an empty set that means "asked for", not "untouched".217 answer = arrange(t, handler, slug, "collections", "all", "show")218 if !strings.Contains(answer, countOf(everything)) {219 t.Errorf("showing everything did not give the %d features back:\n%s", everything, answer)220 }221 page = openExplorer(t, handler, slug, world)222 if got := idStrings(t, entryOf(t, page)["hidden"]); len(got) != 0 {223 t.Errorf("hidden = %v after showing everything", got)224 }225}226227// TestTheIslandRecordsFoldsAndUnfolds is the section ledger on both volumes:228// the key folded is the section's own key -- the viewer's zones section on the229// city, the producer's group on the planet -- and unfolding everything empties230// the ledger rather than writing an "open" beside every name.231func TestTheIslandRecordsFoldsAndUnfolds(t *testing.T) {232 for _, tt := range []struct {233 slug string234 section string235 }{236 {"bend-or", "zones"},237 {"mars", "group-Nomenclature"},238 } {239 t.Run(tt.slug, func(t *testing.T) {240 volume := corpusVolume(t, tt.slug)241 world := firstWorld(t, volume)242 handler := newApp(t, volume)243 openExplorer(t, handler, tt.slug, world)244245 arrange(t, handler, tt.slug, "sections", "section", tt.section, "open", "0")246 page := openExplorer(t, handler, tt.slug, world)247 if got := idStrings(t, entryOf(t, page)["collapsed"]); !reflect.DeepEqual(got, []string{tt.section}) {248 t.Errorf("collapsed = %v, want [%s]", got, tt.section)249 }250 // The page agrees: the folded section wears its fold.251 if !strings.Contains(page, `data-layer-section="`+tt.section+`"`) {252 t.Fatalf("the page has no section %s to have folded", tt.section)253 }254255 arrange(t, handler, tt.slug, "sections", "all", "unfold")256 page = openExplorer(t, handler, tt.slug, world)257 if got := idStrings(t, entryOf(t, page)["collapsed"]); len(got) != 0 {258 t.Errorf("collapsed = %v after unfolding everything", got)259 }260 })261 }262}263264// TestTheLabelLedgerKeepsOnlyDisagreements is the label ladder's record: a265// flip that disagrees with the curation is stored as "<collection>=<policy>",266// a flip back to the curated word drops the override rather than storing it,267// and turning every toggle over and back leaves the ledger empty -- which is268// what the recorded label-override-set, label-override-dropped and269// label-ladder-restored steps were each pinning.270func TestTheLabelLedgerKeepsOnlyDisagreements(t *testing.T) {271 const slug = "bend-or"272 volume := corpusVolume(t, slug)273 world := firstWorld(t, volume)274 payload := readCorpusWorld(t, slug, world)275 handler := newApp(t, volume)276 openExplorer(t, handler, slug, world)277278 ledger := func() []string {279 return idStrings(t, entryOf(t, openExplorer(t, handler, slug, world))["labels"])280 }281 flip := func(id string) {282 arrange(t, handler, slug, "labels", "collection", id, "flip", "1")283 }284285 // A speaking area silenced disagrees with its curation and is recorded.286 speaking := collectionNamed(t, payload, "Zoning")287 flip(speaking.NativeID)288 if got := ledger(); !reflect.DeepEqual(got, []string{speaking.NativeID + "=quiet"}) {289 t.Errorf("labels = %v, want the one disagreement recorded", got)290 }291 // Flipped back, the override has nothing left to say and is dropped.292 flip(speaking.NativeID)293 if got := ledger(); len(got) != 0 {294 t.Errorf("labels = %v; a flip back to the curated word should drop the override", got)295 }296297 // A quiet collection asked to speak is the disagreement spelled the other298 // way round.299 quiet := collectionNamed(t, payload, "Watersheds")300 if quiet.Attrs["atlas.label.policy"] != "quiet" {301 t.Fatalf("the corpus no longer curates %s quiet; this test needs a quiet collection", quiet.Title)302 }303 flip(quiet.NativeID)304 if got := ledger(); !reflect.DeepEqual(got, []string{quiet.NativeID + "=always"}) {305 t.Errorf("labels = %v, want the quiet collection's override", got)306 }307308 // Every toggle over and back restores the ladder exactly and leaves no309 // overrides behind. The rows that wear a toggle here are the areas: a310 // path collection has no policy to flip, and the city's points draw as311 // plain pins.312 flip(quiet.NativeID)313 for turn := 0; turn < 2; turn++ {314 for _, collection := range payload.Collections {315 if collection.Kind == "area" {316 flip(collection.NativeID)317 }318 }319 }320 if got := ledger(); len(got) != 0 {321 t.Errorf("labels = %v after turning every toggle over and back", got)322 }323}324325// TestTheCameraIsTheSeamsEchoRoundedForTheRecord is the two keys the server326// cannot originate, checked as the round trip they actually are: the seam327// reports a settled camera, and the island already carries it back in the328// same answer -- rounded the way the harness that used to read it rounded,329// whole world units for the centre with a half going up, three decimals for330// the zoom.331func TestTheCameraIsTheSeamsEchoRoundedForTheRecord(t *testing.T) {332 const slug = "bend-or"333 volume := corpusVolume(t, slug)334 world := firstWorld(t, volume)335 handler := newApp(t, volume)336 openExplorer(t, handler, slug, world)337338 // The report answers with the island alone, which is how the camera it339 // just wrote becomes readable without a second request.340 answer := post(t, handler, "/session/view", url.Values{341 "volume": {slug}, "world": {world},342 "x": {"120.5"}, "y": {"-40.5"}, "zoom": {"6.2504"},343 })344 if answer.Code != http.StatusOK {345 t.Fatalf("the camera report answered %d", answer.Code)346 }347 if !strings.Contains(answer.Body.String(), `id="atlas-session-island"`) {348 t.Fatalf("the camera report answered without the island:\n%s", answer.Body)349 }350351 entry := entryOf(t, answer.Body.String())352 holdKeys(t, entry)353 // Math.round's convention: a half goes toward positive infinity on both354 // axes, so 120.5 climbs to 121 and -40.5 climbs to -40.355 center, _ := entry["center"].([]any)356 if len(center) != 2 || center[0] != 121.0 || center[1] != -40.0 {357 t.Errorf("center = %v, want [121 -40]: the echo rounds half up", entry["center"])358 }359 if entry["zoom"] != 6.25 {360 t.Errorf("zoom = %v, want 6.25: the echo keeps three decimals", entry["zoom"])361 }362}363364// TestALensIsRecordedAsItsIndex is the planet's variant-second step: a lens is365// an arrangement of one world, not a different ground, so the island carries366// the index of the lens the reader chose -- and the overview's docking rides367// in the same record.368func TestALensIsRecordedAsItsIndex(t *testing.T) {369 const slug = "mars"370 volume := corpusVolume(t, slug)371 world := firstWorld(t, volume)372 handler := newApp(t, volume)373 openExplorer(t, handler, slug, world)374375 arrange(t, handler, slug, "lens", "lens", "MOLA Elevation")376 page := openExplorer(t, handler, slug, world)377 entry := entryOf(t, page)378 if lens, _ := entry["lens"].(float64); lens != 1 {379 t.Errorf("lens = %v, want 1: MOLA Elevation is the payload's second lens", entry["lens"])380 }381 // The page agrees with the record: the chosen lens is the one the picker382 // marks current.383 if !strings.Contains(page, "MOLA Elevation") {384 t.Errorf("the page no longer names the lens the island recorded:\n%s", page)385 }386387 arrange(t, handler, slug, "overview", "docked", "1")388 entry = entryOf(t, openExplorer(t, handler, slug, world))389 if entry["overviewDocked"] != true {390 t.Errorf("overviewDocked = %v after docking the overview", entry["overviewDocked"])391 }392 holdKeys(t, entry)393}394395// TestWhatIsNotTheIslandsLeavesItUntouched is the other half of the contract:396// the island speaks for the arrangement, and the interactions that are not397// arrangement -- a search raised and cleared, a highlight set raised and398// cleared, the sidebar, the grid -- must neither move its values nor teach it399// new keys. It is what the recorded search-a, and-cleared, sidebar-collapsed400// and grid-ascended steps were really saying: the entry those steps recorded401// was the entry the step before them recorded.402//403// One move is the arrangement's, and it is asserted first rather than worked404// around: the first search with something to say brings the panel out, once,405// and dockFolded records it. Everything after runs against a panel already406// out, which is the state every recorded step past "initial" was in too.407func TestWhatIsNotTheIslandsLeavesItUntouched(t *testing.T) {408 const slug = "bend-or"409 volume := corpusVolume(t, slug)410 world := firstWorld(t, volume)411 payload := readCorpusWorld(t, slug, world)412 handler := newApp(t, volume)413 openExplorer(t, handler, slug, world)414415 if entry := entryOf(t, openExplorer(t, handler, slug, world)); entry["dockFolded"] != true {416 t.Fatalf("the panel did not open folded: dockFolded = %v", entry["dockFolded"])417 }418 arrange(t, handler, slug, "search", "q", "a")419 arrange(t, handler, slug, "search", "q", "")420 before := entryOf(t, openExplorer(t, handler, slug, world))421 if before["dockFolded"] != false {422 t.Fatalf("the first search did not bring the panel out: dockFolded = %v",423 before["dockFolded"])424 }425426 arrange(t, handler, slug, "search", "q", "mill")427 arrange(t, handler, slug, "search", "q", "")428 arrange(t, handler, slug, "highlight", "feature", featureNamed(t, payload, "MPO Boundary"))429 arrange(t, handler, slug, "highlight", "feature", featureNamed(t, payload, "Baker Lake"))430 arrange(t, handler, slug, "highlight", "all", "clear")431 arrange(t, handler, slug, "sidebar", "open", "0")432 arrange(t, handler, slug, "grid", "system", "geohash")433 arrange(t, handler, slug, "grid", "cell", "")434 arrange(t, handler, slug, "grid", "system", "toggle")435436 after := entryOf(t, openExplorer(t, handler, slug, world))437 holdKeys(t, after)438 if !reflect.DeepEqual(after, before) {439 t.Errorf("interactions that are not the arrangement moved the island:\nbefore %v\nafter %v",440 before, after)441 }442}