clone

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

main
atlas / internal / app / events_test.go
events_test.go 12.6K
1package app_test23import (4 "bufio"5 "bytes"6 "context"7 "io"8 "net/http"9 "net/http/httptest"10 "regexp"11 "strconv"12 "strings"13 "testing"14 "time"1516 "github.com/FelineStateMachine/atlas/internal/app"17 "github.com/FelineStateMachine/atlas/internal/app/hostenv"18)1920// The import flow and the events stream are one story: an import rescans, and21// what the rescan moved is announced to every page that is open.2223// stream opens an SSE connection and reads it in the background, returning a24// reader for the events and a stop function. It waits for the connection's25// opening comment, so a test that publishes afterwards cannot race the26// subscription.27func openStream(t *testing.T, base, query string) (*bufio.Reader, func()) {28 t.Helper()29 ctx, stop := context.WithCancel(context.Background())30 request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/events"+query, nil)31 if err != nil {32 stop()33 t.Fatal(err)34 }35 response, err := http.DefaultClient.Do(request) //nolint:bodyclose // closed by the stop below36 if err != nil {37 stop()38 t.Fatal(err)39 }40 if kind := response.Header.Get("Content-Type"); kind != "text/event-stream" {41 stop()42 t.Fatalf("the events stream answered %q", kind)43 }44 reader := bufio.NewReader(response.Body)45 hello, err := reader.ReadString('\n')46 if err != nil || !strings.HasPrefix(hello, ":") {47 stop()48 t.Fatalf("the stream opened with %q, %v; want the connection comment", hello, err)49 }50 return reader, func() {51 stop()52 response.Body.Close()53 }54}5556// readEvent reads one whole event: its name and its data lines joined.57func readEvent(t *testing.T, reader *bufio.Reader) (string, string) {58 t.Helper()59 var name string60 var data []string61 for {62 line, err := reader.ReadString('\n')63 if err != nil {64 t.Fatalf("reading an event: %v", err)65 }66 line = strings.TrimRight(line, "\n")67 switch {68 case line == "" && name != "":69 return name, strings.Join(data, "\n")70 case strings.HasPrefix(line, "event: "):71 name = strings.TrimPrefix(line, "event: ")72 case strings.HasPrefix(line, "data: "):73 data = append(data, strings.TrimPrefix(line, "data: "))74 }75 }76}7778// importRegions is the import region as each of one import's writes rendered79// it, in order: an import answers with the whole region once per state it80// reaches, and the last of them is what the reader is left looking at. Every81// one of them must be the region morphed onto itself -- an append is what let82// the states of a run pile up and outlive it.83func importRegions(t *testing.T, body string) []string {84 t.Helper()85 var regions []string86 for _, chunk := range strings.Split(body, "<hx-partial ")[1:] {87 head, rest, ok := strings.Cut(chunk, ">")88 if !ok {89 t.Fatalf("a partial with no end to its opening tag:\n%s", body)90 }91 if !strings.Contains(head, `hx-target="#atlas-import"`) ||92 !strings.Contains(head, `hx-swap="outerMorph"`) {93 t.Errorf("an import answered with <hx-partial %s>, which is not the region morphed onto itself", head)94 }95 region, _, ok := strings.Cut(rest, "</hx-partial>")96 if !ok {97 t.Fatalf("a partial that never closes:\n%s", body)98 }99 regions = append(regions, region)100 }101 if len(regions) == 0 {102 t.Fatalf("the import answered with no partials at all:\n%s", body)103 }104 return regions105}106107// importRows counts the rows a rendering of the region holds. One import is108// one row, so this is 1 while a run is under way and 0 when a run left109// nothing behind.110func importRows(region string) int { return strings.Count(region, `data-state="`) }111112var importRunID = regexp.MustCompile(`id="(atlas-import-run-\d+)"`)113114func TestImportAnnouncesWhatArrived(t *testing.T) {115 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))116 host.volumes.arriving = volume("mars", "Mars", marsStamp)117 host.pick = func(ctx context.Context) (io.ReadCloser, string, error) {118 return io.NopCloser(strings.NewReader("bundle bytes")), "mars-20260801-68e141f26b1a.atlas", nil119 }120121 server := httptest.NewServer(handler)122 defer server.Close()123124 watchingMars, stopMars := openStream(t, server.URL, "?volume=mars")125 defer stopMars()126 watchingNothing, stopNothing := openStream(t, server.URL, "")127 defer stopNothing()128129 response, err := http.Post(server.URL+"/bundles/import", "application/x-www-form-urlencoded", nil)130 if err != nil {131 t.Fatal(err)132 }133 rows, err := io.ReadAll(response.Body)134 response.Body.Close()135 if err != nil {136 t.Fatal(err)137 }138 if response.StatusCode != http.StatusOK {139 t.Fatalf("the import answered %d", response.StatusCode)140 }141 for _, want := range []string{`data-state="picking"`, `data-state="installing"`, `data-state="installed"`, "Mars"} {142 if !bytes.Contains(rows, []byte(want)) {143 t.Errorf("the import rows do not carry %q:\n%s", want, rows)144 }145 }146147 // One run is one row: every state is the same row rendered again, and the148 // row that ends the run is the good end, which takes itself away.149 regions := importRegions(t, string(rows))150 held := importRunID.FindStringSubmatch(regions[0])151 if held == nil {152 t.Fatalf("the first row has no identity to morph against:\n%s", regions[0])153 }154 for i, region := range regions {155 if importRows(region) != 1 {156 t.Errorf("write %d of an import rendered %d rows, want the one row the run is:\n%s",157 i, importRows(region), region)158 }159 if got := importRunID.FindStringSubmatch(region); got == nil || got[1] != held[1] {160 t.Errorf("write %d of an import renamed the row it was morphing:\n%s", i, region)161 }162 }163 last := regions[len(regions)-1]164 if !strings.Contains(last, `data-state="installed"`) || !strings.Contains(last, "Mars") {165 t.Errorf("the import's last word was not the volume it installed:\n%s", last)166 }167 if !strings.Contains(last, "import-row-fading") {168 t.Errorf("an import that ended well does not take itself off the screen:\n%s", last)169 }170 if len(host.volumes.installs) != 1 || string(host.volumes.installs[0]) != "bundle bytes" {171 t.Fatalf("the store was handed %q", host.volumes.installs)172 }173174 // Every open page hears that the library moved.175 name, data := readEvent(t, watchingNothing)176 if name != "catalog" {177 t.Fatalf("the first event was %q, want catalog", name)178 }179 if !strings.Contains(data, `target="#atlas-topbar"`) || !strings.Contains(data, "Mars") {180 t.Errorf("the catalog event carries no volume selector:\n%s", data)181 }182183 // Only the page watching the volume whose build moved is told to refetch.184 if name, _ := readEvent(t, watchingMars); name != "catalog" {185 t.Fatalf("the mars stream's first event was %q", name)186 }187 name, data = readEvent(t, watchingMars)188 if name != "refresh" {189 t.Fatalf("the mars stream's second event was %q, want refresh", name)190 }191 if !strings.Contains(data, `hx-get="/v/mars/overworld"`) {192 t.Errorf("the refresh directive does not say where to refetch from:\n%s", data)193 }194195 // The connection watching nothing hears no directive. Read with a196 // deadline: nothing arriving is the assertion.197 done := make(chan struct{})198 go func() {199 defer close(done)200 _, _ = watchingNothing.ReadString('\n')201 }()202 select {203 case <-done:204 t.Error("a connection watching no volume was sent a refresh directive")205 case <-time.After(150 * time.Millisecond):206 }207}208209// A refusal is the one row that stays. It is the only account of what went210// wrong, so it neither fades nor is appended to: the next import morphs the211// region and the run that failed is replaced by the run that follows it.212func TestImportWithoutAPicker(t *testing.T) {213 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))214 got := post(t, handler, "/bundles/import", nil)215 if got.Code != http.StatusOK {216 t.Fatalf("the import answered %d", got.Code)217 }218 body := got.Body.String()219 if !strings.Contains(body, `data-state="refused"`) || !strings.Contains(body, "picker") {220 t.Errorf("a host with no picker did not say so:\n%s", body)221 }222 regions := importRegions(t, body)223 last := regions[len(regions)-1]224 if importRows(last) != 1 {225 t.Errorf("a refusal left %d rows on screen, want the one:\n%s", importRows(last), last)226 }227 if strings.Contains(last, "import-row-fading") {228 t.Errorf("a refusal took itself away before it could be read:\n%s", last)229 }230}231232// A cancelled picker is silent. The reader closed a dialog they opened, and233// the region ends the exchange holding nothing -- which is also the state it234// was in before the button was pressed.235func TestImportCancelledIsSilent(t *testing.T) {236 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))237 host.pick = func(ctx context.Context) (io.ReadCloser, string, error) {238 return nil, "", hostenv.ErrNoSelection239 }240 got := post(t, handler, "/bundles/import", nil)241 if got.Code != http.StatusOK {242 t.Fatalf("the import answered %d", got.Code)243 }244 body := got.Body.String()245 regions := importRegions(t, body)246 last := regions[len(regions)-1]247 if importRows(last) != 0 {248 t.Errorf("a cancelled picker left something on screen:\n%s", last)249 }250 if !strings.Contains(last, " hidden>") {251 t.Errorf("the region of a cancelled import did not put itself away:\n%s", last)252 }253 for _, unwanted := range []string{"nothing was chosen", `data-state="unchanged"`, `data-state="refused"`} {254 if strings.Contains(body, unwanted) {255 t.Errorf("a cancelled picker was announced as %q:\n%s", unwanted, body)256 }257 }258}259260// A page arrives with the region already standing and already put away: the261// morph an import answers with has to find an element of this id, and a262// reader who has imported nothing has nothing to be told about it.263func TestImportRegionStartsEmpty(t *testing.T) {264 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))265 body := get(t, handler, "/v/tunic/overworld", nil).Body.String()266 region, _, ok := strings.Cut(body, "</section>")267 if _, after, found := strings.Cut(region, `<section id="atlas-import"`); !ok || !found {268 t.Fatalf("a first paint carries no import region")269 } else if !strings.Contains(after, " hidden") {270 t.Errorf("the import region stands open on a page nobody has imported anything on:\n%s", after)271 }272 if importRunID.MatchString(body) {273 t.Errorf("a first paint carries an import row")274 }275}276277// Two imports in a row are two rows over time and one row on screen. The278// second run's row is a different element from the first's, which is what279// makes it a row again rather than the finished one it replaced.280func TestImportsDoNotAccumulate(t *testing.T) {281 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))282 host.volumes.arriving = volume("mars", "Mars", marsStamp)283 host.pick = func(ctx context.Context) (io.ReadCloser, string, error) {284 return io.NopCloser(strings.NewReader("bundle bytes")), "mars-20260801-68e141f26b1a.atlas", nil285 }286287 first := importRegions(t, post(t, handler, "/bundles/import", nil).Body.String())288 // The same build again: a successful import that copies nothing.289 host.volumes.already = true290 second := importRegions(t, post(t, handler, "/bundles/import", nil).Body.String())291292 for _, regions := range [][]string{first, second} {293 for i, region := range regions {294 if importRows(region) != 1 {295 t.Errorf("write %d of an import rendered %d rows, want one:\n%s", i, importRows(region), region)296 }297 }298 }299 firstRun := importRunID.FindStringSubmatch(first[len(first)-1])300 secondRun := importRunID.FindStringSubmatch(second[len(second)-1])301 if firstRun == nil || secondRun == nil {302 t.Fatalf("an import's row has no identity:\n%s\n%s", first[len(first)-1], second[len(second)-1])303 }304 if firstRun[1] == secondRun[1] {305 t.Errorf("a second import reused %q, so its row is the first one's leftovers", firstRun[1])306 }307308 last := second[len(second)-1]309 if !strings.Contains(last, `data-state="unchanged"`) {310 t.Errorf("installing a build the library already held did not say so:\n%s", last)311 }312 if !strings.Contains(last, "import-row-fading") {313 t.Errorf("an import that ended well does not take itself off the screen:\n%s", last)314 }315}316317func TestEventsRefuseANonVolume(t *testing.T) {318 handler, _ := newApp(t)319 if got := get(t, handler, "/events?volume=../etc", nil); got.Code != http.StatusBadRequest {320 t.Errorf("watching %q answered %d", "../etc", got.Code)321 }322}323324// The record schema is versioned, and a record from a schema this build does325// not know is passed over rather than half-read.326func TestSessionSchemaGate(t *testing.T) {327 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))328 if err := host.sessions.Save("volume.tunic.json",329 []byte(`{"schema":`+strconv.Itoa(app.SessionSchema+1)+`,"world":"nowhere"}`)); err != nil {330 t.Fatal(err)331 }332 got := get(t, handler, "/v/tunic/overworld", nil)333 if got.Code != http.StatusOK {334 t.Fatalf("the explorer answered %d over a record it cannot read", got.Code)335 }336 held, err := host.sessions.Load("volume.tunic.json")337 if err != nil {338 t.Fatal(err)339 }340 if !bytes.Contains(held, []byte(`"world":"overworld"`)) {341 t.Errorf("the unreadable record was not replaced: %s", held)342 }343}