1package island23import (4 "net/http"5 "net/url"6 "strconv"7 "strings"8 "testing"9)1011// The display logic, held to the corpus.12//13// The island (island_test.go) checks the arrangement a reader can reach. This14// checks what the application decides *from* that arrangement -- the counts,15// the ladder, the cull -- which is the half that used to live in the browser16// and now runs once, in Go. It is read off the rendered regions rather than17// off the Go values on purpose: a build whose model flipped and whose markup18// did not is wrong, and this is where that shows.19//20// The numbers these tests hold the page to are not copied in from a21// recording: they are computed from the corpus payloads, so the page and the22// corpus are compared with each other and a re-extraction moves the question23// and the answer together.2425// features spells a count the way the footer does.26func features(n int) string {27 if n == 1 {28 return ">1 feature<"29 }30 return ">" + strconv.Itoa(n) + " features<"31}3233// TestTheFilterReadsAndAcrossOrWithin is the AND-across, OR-within rule on the34// volume that can express it. Highlighting one area and one waterbody asks two35// questions of every point; the city's whole ground lies inside the MPO36// boundary and Baker Lake holds no annotated place, so the first highlight37// culls nothing and the second takes every point and leaves the ground.38func TestTheFilterReadsAndAcrossOrWithin(t *testing.T) {39 const slug = "bend-or"40 volume := corpusVolume(t, slug)41 world := firstWorld(t, volume)42 payload := readCorpusWorld(t, slug, world)43 handler := newApp(t, volume)4445 points := pointTotal(t, slug, world)46 shapes := shapeTotal(payload)47 everything := points + shapes4849 page := get(t, handler, "/v/"+slug+"/"+world)50 if page.Code != http.StatusOK {51 t.Fatalf("the explorer answered %d", page.Code)52 }53 if !strings.Contains(page.Body.String(), features(everything)) {54 t.Errorf("the untouched world does not draw the corpus's %d features", everything)55 }5657 // One highlight of one collection: the question is "inside the MPO58 // boundary", which every point in the city answers, so nothing is culled.59 answer := post(t, handler, "/session/highlight", url.Values{60 "volume": {slug}, "feature": {featureNamed(t, payload, "MPO Boundary")},61 })62 if answer.Code != http.StatusOK {63 t.Fatalf("the highlight answered %d: %s", answer.Code, answer.Body)64 }65 if !strings.Contains(answer.Body.String(), features(everything)) {66 t.Errorf("one highlight over the whole city culled something:\n%s", answer.Body)67 }6869 // A second highlight from a *different* collection narrows the question70 // to the ground the two share. Baker Lake holds no annotated place, so71 // every point goes and only the ground is left standing.72 answer = post(t, handler, "/session/highlight", url.Values{73 "volume": {slug}, "feature": {featureNamed(t, payload, "Baker Lake")},74 })75 if answer.Code != http.StatusOK {76 t.Fatalf("the second highlight answered %d", answer.Code)77 }78 if !strings.Contains(answer.Body.String(), features(shapes)) {79 t.Errorf("two highlights across two collections did not read as AND:\n%s", answer.Body)80 }81 if !strings.Contains(answer.Body.String(), ">filtered<") {82 t.Error("the panel does not say why its count dropped")83 }8485 // Clearing gives them all back.86 answer = post(t, handler, "/session/highlight", url.Values{87 "volume": {slug}, "all": {"clear"},88 })89 if !strings.Contains(answer.Body.String(), features(everything)) {90 t.Errorf("clearing the highlights did not give the features back:\n%s", answer.Body)91 }92}9394// TestTheLabelLadderReadsTheCuration holds the legend's label toggles to the95// curation the corpus itself declares: every area collection wears a toggle96// and speaks unless the payload curated it quiet, and a path collection wears97// no toggle at all -- its names have no policy to flip.98func TestTheLabelLadderReadsTheCuration(t *testing.T) {99 const slug = "bend-or"100 volume := corpusVolume(t, slug)101 world := firstWorld(t, volume)102 payload := readCorpusWorld(t, slug, world)103 handler := newApp(t, volume)104105 // The ladder the corpus curates: areas speak unless marked quiet.106 var wantSpeaking, wantSilent []string107 var pathCollection string108 for _, collection := range payload.Collections {109 switch collection.Kind {110 case "area":111 if collection.Attrs["atlas.label.policy"] == "quiet" {112 wantSilent = append(wantSilent, collection.NativeID)113 } else {114 wantSpeaking = append(wantSpeaking, collection.NativeID)115 }116 case "path":117 pathCollection = collection.NativeID118 }119 }120 sortStrings(wantSpeaking)121 sortStrings(wantSilent)122 if len(wantSpeaking) == 0 || len(wantSilent) == 0 || pathCollection == "" {123 t.Fatalf("the corpus no longer expresses the ladder: speaking %v, silent %v, path %q",124 wantSpeaking, wantSilent, pathCollection)125 }126127 page := get(t, handler, "/v/"+slug+"/"+world)128 speaking, silent := ladderOf(page.Body.String())129 if strings.Join(speaking, ",") != strings.Join(wantSpeaking, ",") {130 t.Errorf("speaking = %v, want %v", speaking, wantSpeaking)131 }132 if strings.Join(silent, ",") != strings.Join(wantSilent, ",") {133 t.Errorf("silent = %v, want %v", silent, wantSilent)134 }135136 // A flip that disagrees with the curation moves the button; the record's137 // side of this is held in island_test.go, and this is the button's side.138 flipped := wantSpeaking[0]139 answer := post(t, handler, "/session/labels", url.Values{140 "volume": {slug}, "collection": {flipped}, "flip": {"1"},141 })142 speaking, silent = ladderOf(answer.Body.String())143 if len(speaking) != len(wantSpeaking)-1 || len(silent) != len(wantSilent)+1 {144 t.Errorf("after one flip: speaking = %v, silent = %v", speaking, silent)145 }146147 // Silencing is the reader's own choice and a path never wears a toggle,148 // so the paths stay out of the ladder however it is turned over.149 if strings.Contains(answer.Body.String(), `data-label-toggle="`+pathCollection+`"`) {150 t.Error("a path collection was offered a label toggle")151 }152}153154// ladderOf reads the label ladder off the rendered legend, the way a browser155// wears it: the aria-pressed state of every toggle, in the order they156// appear, sorted.157func ladderOf(page string) (speaking, silent []string) {158 const marker = `data-label-toggle="`159 for at := 0; ; {160 found := strings.Index(page[at:], marker)161 if found < 0 {162 break163 }164 at += found + len(marker)165 end := strings.IndexByte(page[at:], '"')166 if end < 0 {167 break168 }169 id := page[at : at+end]170 // aria-pressed follows on this button and nowhere else, so the171 // window is the button's own tag: reading further would let the last172 // toggle in the legend take its answer from whatever pressable chrome173 // happened to be rendered after it.174 rest := page[at:]175 if next := strings.Index(rest, "</button>"); next >= 0 {176 rest = rest[:next]177 }178 if strings.Contains(rest, `aria-pressed="true"`) {179 speaking = append(speaking, id)180 } else {181 silent = append(silent, id)182 }183 }184 sortStrings(speaking)185 sortStrings(silent)186 return speaking, silent187}188189func sortStrings(values []string) {190 for i := 1; i < len(values); i++ {191 for j := i; j > 0 && values[j] < values[j-1]; j-- {192 values[j], values[j-1] = values[j-1], values[j]193 }194 }195}196197// TestTheLabelToggleWearsTheMarkOfWhatItAsks holds the label button's glyph to198// the reference's two, because the clean-room template once hardcoded one.199//200// The two rows that wear the control are not asking the same question. An201// area draws its names *on* the ground and the toggle is whether they speak202// unasked, which the reference drew as a bar over a stem. A point collection203// curated as text has no marker to fall back on -- the names are the whole of204// how it draws -- and the reference drew a luggage tag. Rendered with one205// glyph, the legend told the reader the two rows offered the same thing.206//207// The recorded fixture that could say all three words in one page is not in208// the public corpus, so the three words are said by a volume built here: one209// area collection, one point collection curated as text, and one row of plain210// pins that wears no button at all and keeps the column as space.211func TestTheLabelToggleWearsTheMarkOfWhatItAsks(t *testing.T) {212 handler := newApp(t, glyphVolume())213 page := get(t, handler, "/v/glyphs/overworld")214 if page.Code != http.StatusOK {215 t.Fatalf("the explorer answered %d", page.Code)216 }217 body := page.Body.String()218219 // The first path of each mark, which is enough to tell them apart and220 // short enough to survive a formatter.221 const (222 bar = `d="M3.5 3.5h9M8 3.5v9"`223 tag = `d="M8.6 2.5H2.5v6.1`224 )225 for _, tt := range []struct {226 id string227 what string228 want string229 wrong string230 }{231 {"overworld/layer/901", "Regions, an area", bar, tag},232 {"overworld/layer/902", "Districts, points curated as text", tag, bar},233 } {234 button := toggleMarkup(body, tt.id)235 switch {236 case button == "":237 t.Errorf("%s wears no label toggle at all", tt.what)238 case !strings.Contains(button, tt.want):239 t.Errorf("%s wears the wrong mark: %s", tt.what, button)240 case strings.Contains(button, tt.wrong):241 t.Errorf("%s wears both marks: %s", tt.what, button)242 }243 }244245 // A plain pin row has no policy to flip, so it has no button -- and the246 // column is held open anyway, which is the half a missing element would247 // silently get right and a wrong element would silently get wrong.248 pins := rowMarkup(body, "overworld/layer/903")249 if pins == "" {250 t.Fatal("the plain pin row is not in the legend")251 }252 if strings.Contains(pins, `class="label-toggle"`) {253 t.Errorf("a pin row was offered a label toggle: %s", pins)254 }255 if !strings.Contains(pins, `class="label-toggle-spacer"`) {256 t.Errorf("a pin row does not hold its column open: %s", pins)257 }258}259260// glyphVolume is the three-word volume the glyph test asks for.261func glyphVolume() *corpusBundle {262 held := corpusBundleOf("glyphs", "Glyphs", "overworld")263 held.entries["worlds/overworld.json"] = []byte(`{"lenses":[],"collections":[264 {"id":901,"title":"Regions","kind":"area","attrs":{"atlas.geometry.kind":"area"},"features":[265 {"id":91,"title":"North","geometry":[{"type":"Polygon","coordinates":[[[-121.30,44.00],[-121.25,44.00],[-121.25,44.05],[-121.30,44.05],[-121.30,44.00]]]}]}]},266 {"id":902,"title":"Districts","kind":"point","attrs":{"atlas.geometry.kind":"point","atlas.render.as":"text"}},267 {"id":903,"title":"Buildings","kind":"point","attrs":{"atlas.geometry.kind":"point"}}268 ]}`)269 return held270}271272// toggleMarkup is one label button, from its own attribute to its close. The273// window stops at `</button>` for the reason ladderOf's does: the last toggle274// in the legend would otherwise take its answer from whatever was rendered275// after it.276func toggleMarkup(page, collection string) string {277 at := strings.Index(page, `data-label-toggle="`+collection+`"`)278 if at < 0 {279 return ""280 }281 rest := page[at:]282 if end := strings.Index(rest, "</button>"); end >= 0 {283 return rest[:end]284 }285 return rest286}287288// rowMarkup is one collection's row, from the checkbox that names it to the289// end of the label that holds it. Nothing nests a second label inside a290// category row, so the first close is the row's own.291func rowMarkup(page, collection string) string {292 at := strings.Index(page, `data-collection="`+collection+`"`)293 if at < 0 {294 return ""295 }296 rest := page[at:]297 if end := strings.Index(rest, "</label>"); end >= 0 {298 return rest[:end]299 }300 return rest301}302303// TestTheLegendTreeIsOneTree checks the sections the tree makes: the viewer's304// own Zones section for ungrouped shape collections, unshifted to the front305// and open, and the producer's groups after it.306func TestTheLegendTreeIsOneTree(t *testing.T) {307 const slug = "bend-or"308 volume := corpusVolume(t, slug)309 world := firstWorld(t, volume)310 payload := readCorpusWorld(t, slug, world)311 handler := newApp(t, volume)312 page := get(t, handler, "/v/"+slug+"/"+world).Body.String()313314 zones := strings.Index(page, `data-layer-section="zones"`)315 heritage := strings.Index(page, `data-layer-section="group-Heritage"`)316 switch {317 case zones < 0:318 t.Fatal("the ungrouped shape collections have no section")319 case heritage < 0:320 t.Fatal("the producer's group is missing")321 case zones > heritage:322 t.Error("the viewer's own Zones section does not come first")323 }324 // Every section opens the same way -- open -- on a world nobody has325 // arranged: folding one kind of thing on the reader's behalf assumed a326 // proportion of data no world promises.327 if strings.Contains(page, `class="layer-section is-collapsed" data-layer-section="zones"`) {328 t.Error("the Zones section opens folded, and no section should")329 }330 // A shape collection carries a feature index; the corpus names which.331 if watersheds := collectionNamed(t, payload, "Watersheds"); !strings.Contains(page,332 `data-feature-index="`+watersheds.NativeID+`"`) {333 t.Error("a shape row carries no feature index")334 }335 // The city's point collection is drawn as pins, so it has no unfolding336 // chevron: the affordance appears where the capability does.337 if historic := collectionNamed(t, payload, "Historic Resources"); strings.Contains(page,338 `data-expand-collection="`+historic.NativeID+`"`) {339 t.Error("a point collection was given a feature index")340 }341}342343// TestTheDockListsWhatTheMapDraws is the sync invariant: the footer, the344// panel's count and the list under it all tell the same story, and the345// story is the corpus's own arithmetic.346func TestTheDockListsWhatTheMapDraws(t *testing.T) {347 const slug = "bend-or"348 volume := corpusVolume(t, slug)349 world := firstWorld(t, volume)350 payload := readCorpusWorld(t, slug, world)351 handler := newApp(t, volume)352 page := get(t, handler, "/v/"+slug+"/"+world).Body.String()353354 everything := pointTotal(t, slug, world) + shapeTotal(payload)355 count := strconv.Itoa(everything) + " features<"356 if !strings.Contains(page, `id="visible-count">`+count) {357 t.Error("the footer does not count what the map draws")358 }359 if !strings.Contains(page, `id="dock-count">`+count) {360 t.Error("the panel does not count what the map draws")361 }362 // The list is a shortlist and says when it is one.363 if !strings.Contains(page, "First 100 of") {364 t.Error("the capped list does not say it is capped")365 }366 if rows := strings.Count(page, `class="search-result`); rows != 100 {367 t.Errorf("the dock listed %d rows, want the 100 it caps at", rows)368 }369}370371// TestSearchNarrowsPointsAndNeverTheGround pins the asymmetry the reference372// implementation settled on: a search takes a point off the map and out of373// the list, and never takes the ground out from under it.374func TestSearchNarrowsPointsAndNeverTheGround(t *testing.T) {375 const slug = "bend-or"376 volume := corpusVolume(t, slug)377 world := firstWorld(t, volume)378 payload := readCorpusWorld(t, slug, world)379 handler := newApp(t, volume)380 get(t, handler, "/v/"+slug+"/"+world)381382 answer := post(t, handler, "/session/search", url.Values{383 "volume": {slug}, "q": {"zzzzznothing"},384 })385 body := answer.Body.String()386 // Every point is gone and every shape is still drawn.387 if !strings.Contains(body, features(shapeTotal(payload))) {388 t.Errorf("a search that matches nothing did not leave the ground standing:\n%s", body)389 }390 if !strings.Contains(body, "No visible features match.") {391 t.Error("the empty list does not say why it is empty")392 }393 if !strings.Contains(body, "“zzzzznothing”") {394 t.Error("the panel does not say what it is searching for")395 }396}397398// TestTheCardSaysWhetherItIsOpen is the defect that made a correct count read399// as a blank panel, held in the one place it can be held from Go.400//401// The carried assets/css/pin-detail.css hands the results list over to the402// card with `.dock-body:has(.pin-detail:not([hidden])) .dock-results`. That403// rule asks the card a question, and the only way the card can answer it is404// by wearing `hidden` when it is closed. A card that never wears it answers405// "open" in every state, and the list is hidden under an empty card forever.406//407// Only the attribute can be checked from here; whether the browser then draws408// the list is a real browser's to see. So this walks the four states the409// rule distinguishes and reads the attribute off both places the card is410// rendered -- the dock's own re-render and the card's own region -- because411// two answers that disagree are the same bug wearing a hat.412func TestTheCardSaysWhetherItIsOpen(t *testing.T) {413 const slug = "bend-or"414 volume := corpusVolume(t, slug)415 world := firstWorld(t, volume)416 handler := newApp(t, volume)417 page := get(t, handler, "/v/"+slug+"/"+world)418 if page.Code != http.StatusOK {419 t.Fatalf("the explorer answered %d", page.Code)420 }421 pointFeature, pointTitle := firstPoint(t, slug, world)422423 // The steps run in order against one handler: "back from a card" is only424 // a state if a card was open first.425 steps := []struct {426 name string427 // concern and form are the interaction that reaches the state. The428 // first step has none: it is the page as it is first painted.429 concern string430 form url.Values431 // hidden is what the card must be wearing once the state is reached,432 // and rows is how many results are standing behind it.433 hidden bool434 rows int435 // title is the card's heading when one is open.436 title string437 }{438 {439 name: "a full list and no card",440 hidden: true,441 rows: 100,442 },443 {444 name: "an empty list and no card",445 concern: "search",446 form: url.Values{"q": {"zzzzznothing"}},447 hidden: true,448 rows: 0,449 },450 {451 name: "the list back, still no card",452 concern: "search",453 form: url.Values{"q": {""}},454 hidden: true,455 rows: 100,456 },457 {458 name: "a card open over the list",459 concern: "select",460 form: url.Values{"feature": {pointFeature}, "focus": {"1"}},461 hidden: false,462 rows: 100,463 title: pointTitle,464 },465 {466 name: "back from the card",467 concern: "select",468 form: url.Values{"feature": {""}},469 hidden: true,470 rows: 100,471 },472 }473474 for _, tt := range steps {475 t.Run(tt.name, func(t *testing.T) {476 body := page.Body.String()477 if tt.concern != "" {478 form := url.Values{"volume": {slug}}479 for name, values := range tt.form {480 form[name] = values481 }482 answer := post(t, handler, "/session/"+tt.concern, form)483 if answer.Code != http.StatusOK {484 t.Fatalf("/session/%s answered %d: %s", tt.concern, answer.Code, answer.Body)485 }486 body = answer.Body.String()487 }488489 tags := openingTags(body, `<article id="atlas-detail"`)490 if len(tags) == 0 {491 t.Fatalf("the answer carries no card at all:\n%s", body)492 }493 for _, tag := range tags {494 if wearing := strings.Contains(tag, " hidden"); wearing != tt.hidden {495 t.Errorf("the card is %q; hidden = %v, want %v", tag, wearing, tt.hidden)496 }497 }498499 // The list is never the one hidden: the reference implementation500 // left `#dock-results` alone and let the card's rule cover it.501 for _, tag := range openingTags(body, `<div class="dock-results"`) {502 if strings.Contains(tag, " hidden") {503 t.Errorf("the results list hid itself: %q", tag)504 }505 }506 if rows := strings.Count(body, `class="search-result`); rows != tt.rows {507 t.Errorf("the list carries %d rows, want %d", rows, tt.rows)508 }509510 // A card that is open has something in it, and a card that is511 // closed has nothing: the emptiness is still true, it is simply512 // no longer the thing the stylesheet is asked to read.513 switch {514 case tt.title != "":515 if !strings.Contains(body, `id="detail-title">`+tt.title+`<`) {516 t.Errorf("the open card does not name %q:\n%s", tt.title, body)517 }518 if !strings.Contains(body, `id="close-detail"`) {519 t.Error("the open card offers no way back to the list")520 }521 default:522 if strings.Contains(body, `id="detail-title"`) {523 t.Error("a closed card still holds a heading")524 }525 }526 })527 }528}529530// TestTheCardCarriesAnEmptyCellRow holds the one row of the card the server531// renders and does not fill.532//533// Where a point stands is in the payload and the card writes it; what that534// place is *called* is the analysis lane's, and the server has no access to535// it. So the row is rendered present, empty and hidden, and the seam fills it536// and un-hides it after every swap -- the same division the footer's "N of M"537// sentence is written across.538//539// A shape has no point to name and gets no row at all. Both cards are asked540// for, because a row that appears on a shape's card is a row that would sit541// there empty forever.542func TestTheCardCarriesAnEmptyCellRow(t *testing.T) {543 const slug = "bend-or"544 volume := corpusVolume(t, slug)545 world := firstWorld(t, volume)546 payload := readCorpusWorld(t, slug, world)547 handler := newApp(t, volume)548 if page := get(t, handler, "/v/"+slug+"/"+world); page.Code != http.StatusOK {549 t.Fatalf("the explorer answered %d", page.Code)550 }551 pointFeature, _ := firstPoint(t, slug, world)552553 for _, tt := range []struct {554 name string555 feature string556 // carries is whether the card is about somewhere a cell system can557 // name, which is what earns it the row.558 carries bool559 }{560 {name: "a point", feature: pointFeature, carries: true},561 {name: "a shape", feature: featureNamed(t, payload, "MPO Boundary"), carries: false},562 } {563 t.Run(tt.name, func(t *testing.T) {564 answer := post(t, handler, "/session/select", url.Values{565 "volume": {slug}, "feature": {tt.feature},566 })567 if answer.Code != http.StatusOK {568 t.Fatalf("/session/select answered %d: %s", answer.Code, answer.Body)569 }570 body := answer.Body.String()571 if !strings.Contains(body, `id="detail-title"`) {572 t.Fatalf("no card came back for %s:\n%s", tt.feature, body)573 }574 held := strings.Contains(body, `id="detail-cell-field"`)575 if held != tt.carries {576 t.Fatalf("the cell row is present = %v, want %v:\n%s", held, tt.carries, body)577 }578 if !tt.carries {579 return580 }581 // Empty, and hidden while it is empty: a row wearing an address582 // out of the session would be an address nothing keeps current.583 if !strings.Contains(body, `<div id="detail-cell-field" hidden>`) {584 t.Errorf("the row is rendered open over nothing:\n%s", body)585 }586 if !strings.Contains(body, `<dd id="detail-cell"></dd>`) {587 t.Errorf("the server filled a row it cannot answer:\n%s", body)588 }589 // And it stands where the reference put it: after the coordinates,590 // which is the fact it is a second reading of.591 cell := strings.Index(body, `id="detail-cell-field"`)592 if at := strings.Index(body, `id="detail-coordinates-field"`); at < 0 || at > cell {593 t.Errorf("the cell row does not follow the coordinates it names")594 }595 })596 }597}598599// openingTags collects every opening tag in a rendered answer that starts with600// the given prefix, as far as its own `>`. A region can be rendered more than601// once in one answer -- the card is inside the dock and is also its own swap --602// and an attribute worth checking is worth checking on all of them.603func openingTags(page, prefix string) []string {604 var out []string605 for at := 0; ; {606 found := strings.Index(page[at:], prefix)607 if found < 0 {608 return out609 }610 at += found611 end := strings.IndexByte(page[at:], '>')612 if end < 0 {613 return out614 }615 out = append(out, page[at:at+end+1])616 at += end + 1617 }618}