clone

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

feat/atlas-next-horizon
atlas / internal / app / events_test.go
events_test.go 13.5K
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}7778func TestEventsImmediatelyRefreshAStaleVolumeStamp(t *testing.T) {79 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))80 server := httptest.NewServer(handler)81 defer server.Close()8283 ctx, stop := context.WithTimeout(context.Background(), 500*time.Millisecond)84 defer stop()85 request, err := http.NewRequestWithContext(ctx, http.MethodGet,86 server.URL+"/events?volume=tunic&stamp=stale", nil)87 if err != nil {88 t.Fatal(err)89 }90 response, err := http.DefaultClient.Do(request) //nolint:bodyclose // closed below91 if err != nil {92 t.Fatal(err)93 }94 defer response.Body.Close()95 reader := bufio.NewReader(response.Body)96 if hello, err := reader.ReadString('\n'); err != nil || !strings.HasPrefix(hello, ":") {97 t.Fatalf("the stream opened with %q, %v", hello, err)98 }99 name, data := readEvent(t, reader)100 if name != "refresh" || !strings.Contains(data, `hx-get="/v/tunic/`) {101 t.Fatalf("stale stream event = %q %q, want immediate refresh", name, data)102 }103}104105// importRegions is the import region as each of one import's writes rendered106// it, in order: an import answers with the whole region once per state it107// reaches, and the last of them is what the reader is left looking at. Every108// one of them must be the region morphed onto itself -- an append is what let109// the states of a run pile up and outlive it.110func importRegions(t *testing.T, body string) []string {111 t.Helper()112 var regions []string113 for _, chunk := range strings.Split(body, "<hx-partial ")[1:] {114 head, rest, ok := strings.Cut(chunk, ">")115 if !ok {116 t.Fatalf("a partial with no end to its opening tag:\n%s", body)117 }118 if !strings.Contains(head, `hx-target="#atlas-import"`) ||119 !strings.Contains(head, `hx-swap="outerMorph"`) {120 t.Errorf("an import answered with <hx-partial %s>, which is not the region morphed onto itself", head)121 }122 region, _, ok := strings.Cut(rest, "</hx-partial>")123 if !ok {124 t.Fatalf("a partial that never closes:\n%s", body)125 }126 regions = append(regions, region)127 }128 if len(regions) == 0 {129 t.Fatalf("the import answered with no partials at all:\n%s", body)130 }131 return regions132}133134// importRows counts the rows a rendering of the region holds. One import is135// one row, so this is 1 while a run is under way and 0 when a run left136// nothing behind.137func importRows(region string) int { return strings.Count(region, `data-state="`) }138139var importRunID = regexp.MustCompile(`id="(atlas-import-run-\d+)"`)140141func TestImportAnnouncesWhatArrived(t *testing.T) {142 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))143 host.volumes.arriving = volume("mars", "Mars", marsStamp)144 host.pick = func(ctx context.Context) (io.ReadCloser, string, error) {145 return io.NopCloser(strings.NewReader("bundle bytes")), "mars-20260801-68e141f26b1a.atlas", nil146 }147148 server := httptest.NewServer(handler)149 defer server.Close()150151 watchingMars, stopMars := openStream(t, server.URL, "?volume=mars")152 defer stopMars()153 watchingNothing, stopNothing := openStream(t, server.URL, "")154 defer stopNothing()155156 response, err := http.Post(server.URL+"/bundles/import", "application/x-www-form-urlencoded", nil)157 if err != nil {158 t.Fatal(err)159 }160 rows, err := io.ReadAll(response.Body)161 response.Body.Close()162 if err != nil {163 t.Fatal(err)164 }165 if response.StatusCode != http.StatusOK {166 t.Fatalf("the import answered %d", response.StatusCode)167 }168 for _, want := range []string{`data-state="picking"`, `data-state="installing"`, `data-state="installed"`, "Mars"} {169 if !bytes.Contains(rows, []byte(want)) {170 t.Errorf("the import rows do not carry %q:\n%s", want, rows)171 }172 }173174 // One run is one row: every state is the same row rendered again, and the175 // row that ends the run is the good end, which takes itself away.176 regions := importRegions(t, string(rows))177 held := importRunID.FindStringSubmatch(regions[0])178 if held == nil {179 t.Fatalf("the first row has no identity to morph against:\n%s", regions[0])180 }181 for i, region := range regions {182 if importRows(region) != 1 {183 t.Errorf("write %d of an import rendered %d rows, want the one row the run is:\n%s",184 i, importRows(region), region)185 }186 if got := importRunID.FindStringSubmatch(region); got == nil || got[1] != held[1] {187 t.Errorf("write %d of an import renamed the row it was morphing:\n%s", i, region)188 }189 }190 last := regions[len(regions)-1]191 if !strings.Contains(last, `data-state="installed"`) || !strings.Contains(last, "Mars") {192 t.Errorf("the import's last word was not the volume it installed:\n%s", last)193 }194 if !strings.Contains(last, "import-row-fading") {195 t.Errorf("an import that ended well does not take itself off the screen:\n%s", last)196 }197 if len(host.volumes.installs) != 1 || string(host.volumes.installs[0]) != "bundle bytes" {198 t.Fatalf("the store was handed %q", host.volumes.installs)199 }200201 // Every open page hears that the library moved.202 name, data := readEvent(t, watchingNothing)203 if name != "catalog" {204 t.Fatalf("the first event was %q, want catalog", name)205 }206 if !strings.Contains(data, `target="#atlas-topbar"`) || !strings.Contains(data, "Mars") {207 t.Errorf("the catalog event carries no volume selector:\n%s", data)208 }209210 // Only the page watching the volume whose build moved is told to refetch.211 if name, _ := readEvent(t, watchingMars); name != "catalog" {212 t.Fatalf("the mars stream's first event was %q", name)213 }214 name, data = readEvent(t, watchingMars)215 if name != "refresh" {216 t.Fatalf("the mars stream's second event was %q, want refresh", name)217 }218 if !strings.Contains(data, `hx-get="/v/mars/overworld"`) {219 t.Errorf("the refresh directive does not say where to refetch from:\n%s", data)220 }221222 // The connection watching nothing hears no directive. Read with a223 // deadline: nothing arriving is the assertion.224 done := make(chan struct{})225 go func() {226 defer close(done)227 _, _ = watchingNothing.ReadString('\n')228 }()229 select {230 case <-done:231 t.Error("a connection watching no volume was sent a refresh directive")232 case <-time.After(150 * time.Millisecond):233 }234}235236// A refusal is the one row that stays. It is the only account of what went237// wrong, so it neither fades nor is appended to: the next import morphs the238// region and the run that failed is replaced by the run that follows it.239func TestImportWithoutAPicker(t *testing.T) {240 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))241 got := post(t, handler, "/bundles/import", nil)242 if got.Code != http.StatusOK {243 t.Fatalf("the import answered %d", got.Code)244 }245 body := got.Body.String()246 if !strings.Contains(body, `data-state="refused"`) || !strings.Contains(body, "picker") {247 t.Errorf("a host with no picker did not say so:\n%s", body)248 }249 regions := importRegions(t, body)250 last := regions[len(regions)-1]251 if importRows(last) != 1 {252 t.Errorf("a refusal left %d rows on screen, want the one:\n%s", importRows(last), last)253 }254 if strings.Contains(last, "import-row-fading") {255 t.Errorf("a refusal took itself away before it could be read:\n%s", last)256 }257}258259// A cancelled picker is silent. The reader closed a dialog they opened, and260// the region ends the exchange holding nothing -- which is also the state it261// was in before the button was pressed.262func TestImportCancelledIsSilent(t *testing.T) {263 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))264 host.pick = func(ctx context.Context) (io.ReadCloser, string, error) {265 return nil, "", hostenv.ErrNoSelection266 }267 got := post(t, handler, "/bundles/import", nil)268 if got.Code != http.StatusOK {269 t.Fatalf("the import answered %d", got.Code)270 }271 body := got.Body.String()272 regions := importRegions(t, body)273 last := regions[len(regions)-1]274 if importRows(last) != 0 {275 t.Errorf("a cancelled picker left something on screen:\n%s", last)276 }277 if !strings.Contains(last, " hidden>") {278 t.Errorf("the region of a cancelled import did not put itself away:\n%s", last)279 }280 for _, unwanted := range []string{"nothing was chosen", `data-state="unchanged"`, `data-state="refused"`} {281 if strings.Contains(body, unwanted) {282 t.Errorf("a cancelled picker was announced as %q:\n%s", unwanted, body)283 }284 }285}286287// A page arrives with the region already standing and already put away: the288// morph an import answers with has to find an element of this id, and a289// reader who has imported nothing has nothing to be told about it.290func TestImportRegionStartsEmpty(t *testing.T) {291 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))292 body := get(t, handler, "/v/tunic/overworld", nil).Body.String()293 region, _, ok := strings.Cut(body, "</section>")294 if _, after, found := strings.Cut(region, `<section id="atlas-import"`); !ok || !found {295 t.Fatalf("a first paint carries no import region")296 } else if !strings.Contains(after, " hidden") {297 t.Errorf("the import region stands open on a page nobody has imported anything on:\n%s", after)298 }299 if importRunID.MatchString(body) {300 t.Errorf("a first paint carries an import row")301 }302}303304// Two imports in a row are two rows over time and one row on screen. The305// second run's row is a different element from the first's, which is what306// makes it a row again rather than the finished one it replaced.307func TestImportsDoNotAccumulate(t *testing.T) {308 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))309 host.volumes.arriving = volume("mars", "Mars", marsStamp)310 host.pick = func(ctx context.Context) (io.ReadCloser, string, error) {311 return io.NopCloser(strings.NewReader("bundle bytes")), "mars-20260801-68e141f26b1a.atlas", nil312 }313314 first := importRegions(t, post(t, handler, "/bundles/import", nil).Body.String())315 // The same build again: a successful import that copies nothing.316 host.volumes.already = true317 second := importRegions(t, post(t, handler, "/bundles/import", nil).Body.String())318319 for _, regions := range [][]string{first, second} {320 for i, region := range regions {321 if importRows(region) != 1 {322 t.Errorf("write %d of an import rendered %d rows, want one:\n%s", i, importRows(region), region)323 }324 }325 }326 firstRun := importRunID.FindStringSubmatch(first[len(first)-1])327 secondRun := importRunID.FindStringSubmatch(second[len(second)-1])328 if firstRun == nil || secondRun == nil {329 t.Fatalf("an import's row has no identity:\n%s\n%s", first[len(first)-1], second[len(second)-1])330 }331 if firstRun[1] == secondRun[1] {332 t.Errorf("a second import reused %q, so its row is the first one's leftovers", firstRun[1])333 }334335 last := second[len(second)-1]336 if !strings.Contains(last, `data-state="unchanged"`) {337 t.Errorf("installing a build the library already held did not say so:\n%s", last)338 }339 if !strings.Contains(last, "import-row-fading") {340 t.Errorf("an import that ended well does not take itself off the screen:\n%s", last)341 }342}343344func TestEventsRefuseANonVolume(t *testing.T) {345 handler, _ := newApp(t)346 if got := get(t, handler, "/events?volume=../etc", nil); got.Code != http.StatusBadRequest {347 t.Errorf("watching %q answered %d", "../etc", got.Code)348 }349}350351// The record schema is versioned, and a record from a schema this build does352// not know is passed over rather than half-read.353func TestSessionSchemaGate(t *testing.T) {354 handler, host := newApp(t, volume("tunic", "TUNIC", tunicStamp))355 if err := host.sessions.Save("volume.tunic.json",356 []byte(`{"schema":`+strconv.Itoa(app.SessionSchema+1)+`,"world":"nowhere"}`)); err != nil {357 t.Fatal(err)358 }359 got := get(t, handler, "/v/tunic/overworld", nil)360 if got.Code != http.StatusOK {361 t.Fatalf("the explorer answered %d over a record it cannot read", got.Code)362 }363 held, err := host.sessions.Load("volume.tunic.json")364 if err != nil {365 t.Fatal(err)366 }367 if !bytes.Contains(held, []byte(`"world":"overworld"`)) {368 t.Errorf("the unreadable record was not replaced: %s", held)369 }370}