rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3// The media grid's frames, its tile as the target and its no-script
4// selection, from rastrillo's ui/mediagrid_browser_test.go at the
5// seed commit, its page read through fixture. fixtureImages, the
6// catch-all /images/ handler, lives in rig_browser_test.go.
7package check
8
9import (
10 "context"
11 "encoding/json"
12 "fmt"
13 "math"
14 "net/http"
15 "net/url"
16 "strings"
17 "testing"
18 "time"
19
20 "github.com/chromedp/cdproto/emulation"
21 "github.com/chromedp/chromedp"
22
23 "amadan.net/rastrillo/fiesta/internal/harness"
24)
25
26// Two pictures the grid has to hold without stretching a tile: a page
27// screenshot three times taller than it is wide, and a banner six times
28// wider than it is tall. SVG so the test owns the ratio, served by the
29// drive's own mux at the routes the seeded tiles name. Not data: URLs:
30// html/template's URL filter writes #ZgotmplZ for one, and a drive that
31// measured the broken-image boxes those leave would pass with the
32// cover and contain rules deleted.
33const (
34 mediaTallSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 900" width="300" height="900"><rect width="300" height="900" fill="#cfd8e3"/><rect width="300" height="60" fill="#2464e0"/></svg>`
35 mediaWideSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 200" width="1200" height="200"><rect width="1200" height="200" fill="#e5e7eb"/><circle cx="100" cy="100" r="60" fill="#2464e0"/></svg>`
36 mediaTallSrc = "/images/tall.svg"
37 mediaWideSrc = "/images/wide.svg"
38)
39
40// mediaPictures serves the two pictures at their routes, ahead of the
41// fixtures' catch-all /images/ handler.
42func mediaPictures(mux *http.ServeMux) {
43 for route, body := range map[string]string{mediaTallSrc: mediaTallSVG, mediaWideSrc: mediaWideSVG} {
44 body := body
45 mux.HandleFunc("GET "+route, func(w http.ResponseWriter, _ *http.Request) {
46 w.Header().Set("Content-Type", "image/svg+xml")
47 fmt.Fprint(w, body)
48 })
49 }
50}
51
52// mediaTiles is how many tiles the seeded grid holds: the two shapes,
53// each at the default cover and at contain, one tile selected (the
54// wide contained one, value wide-contain), every tile linked to
55// /go/<id> and with a kebab, so one grid exercises every control the
56// partial draws. The first tile (tall, value tall) carries a long
57// description, so the clamp has something to clamp.
58const mediaTiles = 4
59
60// mediaPage is the grid in a list card under a bulk bar, in one
61// direction, the way an Images page in select mode is laid out. The
62// ids are for the probes.
63func mediaPage(t *testing.T, dir string) string {
64 t.Helper()
65 return strings.Replace(sizingDoc("media-grid", fixture(t, "mediagrid_browser_test.go", "mediaPage", "")), `dir="ltr"`, `dir="`+dir+`"`, 1)
66}
67
68// mediaMux serves the page in both directions, a landing page for every
69// /go/ href, and the form's action, which hands what it was posted to
70// the drive.
71func mediaMux(t *testing.T, posted chan<- url.Values) *http.ServeMux {
72 t.Helper()
73 mux := sizingMux(t, map[string]string{
74 "/": mediaPage(t, "ltr"),
75 "/rtl": mediaPage(t, "rtl"),
76 "/go/": sizingDoc("landed", `<p id="landed">landed</p>`),
77 })
78 mediaPictures(mux)
79 mux.HandleFunc("POST /select", func(w http.ResponseWriter, r *http.Request) {
80 if err := r.ParseForm(); err != nil {
81 http.Error(w, err.Error(), http.StatusBadRequest)
82 return
83 }
84 posted <- r.PostForm
85 w.Header().Set("Content-Type", "text/html; charset=utf-8")
86 fmt.Fprint(w, sizingDoc("posted", `<p id="posted">posted</p>`))
87 })
88 return mux
89}
90
91// mediaReading is one tile measured: its boxes, the image's, the
92// caption's last line's, the two controls', and whether anything in the
93// tile is cut off by it.
94type mediaReading struct {
95 Name string
96 Tile, Frame, Img, Caption, Last, Link box
97 Check, Kebab *box
98 Overflow float64 // scrollHeight - clientHeight of the tile
99 Contain bool
100 Selected bool
101 Outline string
102 // The picture as decoded, and how the frame fits it: naturalWidth
103 // is 0 for a picture that did not decode, so a 404 or a filtered
104 // src reads as the zero it is.
105 Decoded bool
106 NaturalW, NaturalH int
107 ObjectFit, ObjectPos string
108 Src string
109}
110
111const mediaJS = `(() => {
112 const r = el => { if (!el) return null; const b = el.getBoundingClientRect(); return {L: b.left, T: b.top, R: b.right, B: b.bottom}; };
113 const out = [];
114 document.querySelectorAll("[rst-media-tile]").forEach(tile => {
115 const frame = tile.querySelector("[rst-media-frame]"), cap = tile.querySelector("[rst-media-caption]");
116 const last = cap.querySelector("[rst-media-meta]") || cap.querySelector("[rst-media-desc]");
117 const img = frame.querySelector("img"), is = getComputedStyle(img);
118 out.push({Name: tile.querySelector("[rst-media-name] > :first-child").textContent,
119 Tile: r(tile), Frame: r(frame), Img: r(img), Caption: r(cap), Last: r(last),
120 Decoded: img.complete && img.naturalWidth > 0, NaturalW: img.naturalWidth, NaturalH: img.naturalHeight,
121 ObjectFit: is.objectFit, ObjectPos: is.objectPosition, Src: img.getAttribute("src"),
122 Link: r(tile.querySelector("[rst-media-name] > a")),
123 Check: r(tile.querySelector("[rst-selbox]")), Kebab: r(tile.querySelector("[rst-row-menu] > summary")),
124 Overflow: tile.scrollHeight - tile.clientHeight,
125 Contain: frame.getAttribute("rst-media-frame") === "contain",
126 Selected: !!tile.querySelector("[rst-selbox] input:checked"),
127 Outline: getComputedStyle(tile).outlineStyle + " " + getComputedStyle(tile).outlineWidth});
128 });
129 const gridEl = document.querySelector("[rst-media]"), gs = getComputedStyle(gridEl);
130 const grid = r(gridEl), inner = gridEl.clientWidth - parseFloat(gs.paddingLeft) - parseFloat(gs.paddingRight);
131 const floor = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--rst-target")) * parseFloat(getComputedStyle(document.documentElement).fontSize);
132 return JSON.stringify({Tiles: out, Grid: grid, GridInner: inner, Floor: floor, Width: innerWidth, RTL: document.documentElement.dir === "rtl",
133 Scripted: document.documentElement.dataset.scripted === "1"});
134})()`
135
136type mediaPageReading struct {
137 Tiles []mediaReading
138 Grid box
139 GridInner float64
140 Floor float64
141 Width int
142 RTL bool
143 Scripted bool
144}
145
146func readMedia(t *testing.T, ctx context.Context) mediaPageReading {
147 t.Helper()
148 // Every picture has finished loading, well or badly, before the
149 // reading: complete is true for a decode and for a failure alike,
150 // and naturalWidth tells them apart.
151 settleUntil(t, ctx, `[...document.images].every(i => i.complete)`)
152 var raw string
153 if err := chromedp.Run(ctx, chromedp.Evaluate(mediaJS, &raw)); err != nil {
154 t.Fatalf("reading the grid: %v", err)
155 }
156 var got mediaPageReading
157 if err := json.Unmarshal([]byte(raw), &got); err != nil {
158 t.Fatalf("decoding %q: %v", raw, err)
159 }
160 if len(got.Tiles) != mediaTiles {
161 t.Fatalf("read %d tiles, want %d; the selectors no longer match the partial", len(got.Tiles), mediaTiles)
162 }
163 return got
164}
165
166// insideBox says whether inner is inside outer, to a pixel.
167func insideBox(inner, outer box) bool {
168 return inner.L >= outer.L-1 && inner.T >= outer.T-1 && inner.R <= outer.R+1 && inner.B <= outer.B+1
169}
170
171// TestMediaGridFramesAreEvenAndCaptionsStayInTheTile is the partial's
172// claim, measured: whatever the picture's shape, a 1:3 screenshot or a
173// 6:1 banner, its frame is 4:3 and the image is held inside it; the
174// name, the description and the meta line are inside the tile with
175// nothing cut off; the checkbox and the kebab are at the floor, in the
176// frame's and the caption's start corners, in both directions; one
177// column at a phone's width and several at a desktop's.
178func TestMediaGridFramesAreEvenAndCaptionsStayInTheTile(t *testing.T) {
179 for _, leg := range []struct {
180 name string
181 w, h int64
182 coarse bool
183 dir string
184 floor float64
185 cols int // the least number of columns
186 }{
187 {"1024 mouse", 1024, 768, false, "ltr", 32, 3},
188 {"1024 mouse rtl", 1024, 768, false, "rtl", 32, 3},
189 {"390 touch", 390, 844, true, "ltr", 44, 1},
190 {"390 touch rtl", 390, 844, true, "rtl", 44, 1},
191 {"320 touch", 320, 568, true, "ltr", 44, 1},
192 } {
193 t.Run(leg.name, func(t *testing.T) {
194 rig := mediaRig(t, leg.coarse, mediaMux(t, make(chan url.Values, 4)))
195 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
196 defer cancel()
197 path := "/"
198 if leg.dir == "rtl" {
199 path = "/rtl"
200 }
201 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h), chromedp.Navigate(rig.Origin+path), chromedp.WaitVisible("[rst-media]", chromedp.ByQuery)); err != nil {
202 t.Fatal(err)
203 }
204 requirePointer(t, ctx, leg.coarse)
205 got := readMedia(t, ctx)
206 if got.RTL != (leg.dir == "rtl") {
207 t.Fatalf("the page is dir=%v, this leg wants %s", got.RTL, leg.dir)
208 }
209 if got.Floor != leg.floor {
210 t.Errorf("--rst-target is %vpx, want %v", got.Floor, leg.floor)
211 }
212 lefts := map[int]bool{}
213 for _, tile := range got.Tiles {
214 if tile.Tile.B > got.Tiles[0].Tile.B+1 {
215 continue // a later row
216 }
217 lefts[int(math.Round(tile.Tile.L))] = true
218 }
219 if len(lefts) < leg.cols {
220 t.Errorf("%d column(s) in the first row, want at least %d", len(lefts), leg.cols)
221 }
222 if leg.cols == 1 && len(lefts) != 1 {
223 t.Errorf("a phone shows %d columns, want one", len(lefts))
224 }
225 if leg.cols == 1 {
226 // One column, and the column is the grid: a tile narrower
227 // than the grid would be a grid that reserved room for a
228 // second tile it cannot fit.
229 if tw, gw := got.Tiles[0].Tile.w(), got.GridInner; math.Abs(tw-gw) > 1 {
230 t.Errorf("the one column is %.0fpx wide in a grid whose content box is %.0fpx", tw, gw)
231 }
232 }
233 var selected int
234 for _, tile := range got.Tiles {
235 where := leg.name + ": " + tile.Name
236 if ratio := tile.Frame.w() / (tile.Frame.B - tile.Frame.T); math.Abs(ratio-4.0/3) > 0.02 {
237 t.Errorf("%s: the frame is %.0f×%.0f, ratio %.3f, want 4:3", where, tile.Frame.w(), tile.Frame.B-tile.Frame.T, ratio)
238 }
239 // The picture decoded at the shape the drive drew, so what
240 // is measured below is the real 1:3 or 6:1 image in its
241 // frame and not a broken-image box.
242 wantW, wantH := 300, 900
243 if tile.Src == mediaWideSrc {
244 wantW, wantH = 1200, 200
245 } else if tile.Src != mediaTallSrc {
246 t.Fatalf("%s: the image's src is %q, not a route this drive serves", where, tile.Src)
247 }
248 if !tile.Decoded || tile.NaturalW != wantW || tile.NaturalH != wantH {
249 t.Errorf("%s: the picture decoded %v at %d×%d, want %d×%d", where, tile.Decoded, tile.NaturalW, tile.NaturalH, wantW, wantH)
250 }
251 // cover from the top for the default, contain for Fit: the
252 // two rules that make a tall screenshot show its top and a
253 // logo show whole, read off the engine rather than inferred
254 // from a box the frame's own size would give either way.
255 if tile.Contain {
256 if tile.ObjectFit != "contain" {
257 t.Errorf("%s: Fit contain computes object-fit %q, want contain", where, tile.ObjectFit)
258 }
259 } else if tile.ObjectFit != "cover" || tile.ObjectPos != "50% 0%" {
260 t.Errorf("%s: the default computes object-fit %q at %q, want cover at 50%% 0%% (top centre)", where, tile.ObjectFit, tile.ObjectPos)
261 }
262 if !insideBox(tile.Img, tile.Frame) {
263 t.Errorf("%s: the image %+v is not held inside its frame %+v", where, tile.Img, tile.Frame)
264 }
265 if tile.Img.w() < tile.Frame.w()-1 || (tile.Img.B-tile.Img.T) < (tile.Frame.B-tile.Frame.T)-1 {
266 t.Errorf("%s: the image box %+v does not fill its frame %+v; the frame's shape would show around it", where, tile.Img, tile.Frame)
267 }
268 if !insideBox(tile.Caption, tile.Tile) || !insideBox(tile.Last, tile.Tile) {
269 t.Errorf("%s: the caption %+v or its last line %+v leaves the tile %+v", where, tile.Caption, tile.Last, tile.Tile)
270 }
271 if tile.Caption.T < tile.Frame.B-1 {
272 t.Errorf("%s: the caption starts at %.0f, over the frame that ends at %.0f", where, tile.Caption.T, tile.Frame.B)
273 }
274 if tile.Overflow > 0.5 {
275 t.Errorf("%s: %.0fpx of the tile is cut off; a caption must be visible without scrolling the tile", where, tile.Overflow)
276 }
277 if tile.Check == nil || tile.Kebab == nil {
278 t.Fatalf("%s: no checkbox or no kebab was rendered", where)
279 }
280 for name, b := range map[string]box{"checkbox": *tile.Check, "kebab": *tile.Kebab} {
281 if b.w() < got.Floor-0.5 || (b.B-b.T) < got.Floor-0.5 {
282 t.Errorf("%s: the %s is %.0f×%.0f, under the %.0fpx floor", where, name, b.w(), b.B-b.T, got.Floor)
283 }
284 }
285 if !insideBox(*tile.Check, tile.Frame) {
286 t.Errorf("%s: the checkbox %+v is not inside the frame %+v", where, *tile.Check, tile.Frame)
287 }
288 // The start corners: the checkbox in the frame's, the
289 // kebab at the caption's end, mirrored under dir=rtl.
290 if got.RTL {
291 if tile.Frame.R-tile.Check.R > 8 || tile.Check.T-tile.Frame.T > 8 {
292 t.Errorf("%s: in RTL the checkbox %+v is not in the frame's top right corner (frame %+v)", where, *tile.Check, tile.Frame)
293 }
294 if tile.Kebab.L > tile.Link.L {
295 t.Errorf("%s: in RTL the kebab (%.0f) is not at the left of the name (%.0f)", where, tile.Kebab.L, tile.Link.L)
296 }
297 } else {
298 if tile.Check.L-tile.Frame.L > 8 || tile.Check.T-tile.Frame.T > 8 {
299 t.Errorf("%s: the checkbox %+v is not in the frame's top left corner (frame %+v)", where, *tile.Check, tile.Frame)
300 }
301 if tile.Kebab.R < tile.Link.R {
302 t.Errorf("%s: the kebab (%.0f) is not at the right of the name (%.0f)", where, tile.Kebab.R, tile.Link.R)
303 }
304 }
305 if tile.Selected {
306 selected++
307 if !strings.HasPrefix(tile.Outline, "solid 2px") {
308 t.Errorf("%s: the selected tile's outline is %q, want a solid 2px accent outline", where, tile.Outline)
309 }
310 } else if !strings.HasPrefix(tile.Outline, "none") {
311 t.Errorf("%s: an unselected tile has an outline (%q)", where, tile.Outline)
312 }
313 }
314 if selected != 1 {
315 t.Errorf("%d tiles read as selected, want the one the server checked", selected)
316 }
317 })
318 }
319}
320
321// mediaRig is sizingRig with a mux of the drive's own: the form's
322// action is a handler, not a page.
323func mediaRig(t *testing.T, coarse bool, mux *http.ServeMux) *harness.Rig {
324 t.Helper()
325 var opts []harness.Option
326 if coarse {
327 opts = append(opts, harness.WithCoarsePointer())
328 }
329 return harness.New(t, func(string) http.Handler { return mux }, opts...)
330}
331
332// TestMediaGridTileIsTheTarget is the tile's half of the whole-row
333// rule: a click on the picture, on the description and on the tile's
334// empty corner all open the tile's Href, and the checkbox and the kebab
335// each do their own thing without navigating, with a mouse and with a
336// touch screen.
337func TestMediaGridTileIsTheTarget(t *testing.T) {
338 for _, leg := range []struct {
339 name string
340 w, h int64
341 coarse bool
342 }{{"1280 mouse", 1280, 900, false}, {"390 touch", 390, 844, true}} {
343 t.Run(leg.name, func(t *testing.T) {
344 rig := mediaRig(t, leg.coarse, mediaMux(t, make(chan url.Values, 4)))
345 ctx, cancel := context.WithTimeout(rig.Context(), 180*time.Second)
346 defer cancel()
347 if err := chromedp.Run(ctx, chromedp.EmulateViewport(leg.w, leg.h)); err != nil {
348 t.Fatal(err)
349 }
350 load := func() {
351 t.Helper()
352 if err := chromedp.Run(ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-media]", chromedp.ByQuery)); err != nil {
353 t.Fatal(err)
354 }
355 }
356 load()
357 requirePointer(t, ctx, leg.coarse)
358 // The ids the probes read back: the first tile's link, box
359 // and kebab, by position in the DOM.
360 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const tile = document.querySelector("[rst-media-tile]");
361 tile.id = "tile"; tile.querySelector("[rst-media-frame]").id = "frame"; tile.querySelector("img").id = "img";
362 tile.querySelector("[rst-media-name] > a").id = "link"; tile.querySelector("[rst-selbox] input").id = "check";
363 tile.querySelector("[rst-row-menu]").id = "menu"; tile.querySelector("[rst-row-menu] > summary").id = "kebab";
364 tile.querySelector("[rst-media-desc]").id = "desc"; return true; })()`, nil)); err != nil {
365 t.Fatal(err)
366 }
367 for _, c := range []struct {
368 where, sel string
369 fx, fy float64
370 }{
371 {"the middle of the picture", "#frame", 0.5, 0.5},
372 {"the description", "#desc", 0.5, 0.5},
373 {"the tile's bottom end corner", "#tile", 0.97, 0.97},
374 } {
375 p := probe(t, ctx, c.sel, c.fx, c.fy, 0, 0)
376 if p.Hit != "link" {
377 t.Errorf("%s: at %s the element under the pointer is %s, want the tile's name link", leg.name, c.where, p.Hit)
378 }
379 clickAndLand(t, ctx, p, "/go/tall")
380 load()
381 if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const tile = document.querySelector("[rst-media-tile]");
382 tile.id = "tile"; tile.querySelector("[rst-media-frame]").id = "frame"; tile.querySelector("[rst-media-name] > a").id = "link";
383 tile.querySelector("[rst-selbox] input").id = "check"; tile.querySelector("[rst-row-menu]").id = "menu";
384 tile.querySelector("[rst-row-menu] > summary").id = "kebab"; tile.querySelector("[rst-media-desc]").id = "desc"; return true; })()`, nil)); err != nil {
385 t.Fatal(err)
386 }
387 }
388 box := probe(t, ctx, "#check", 0.5, 0.5, 0, 0)
389 if box.Hit != "check" {
390 t.Errorf("%s: over the checkbox the element under the pointer is %s", leg.name, box.Hit)
391 }
392 clickAndStay(t, ctx, box, "", `document.getElementById("check").checked`)
393 settleUntil(t, ctx, `getComputedStyle(document.getElementById("tile")).outlineStyle === "solid"`)
394 // The pointer lands on the icon inside the summary, so the hit
395 // is read as the control it is inside rather than by its id.
396 kebab := probe(t, ctx, "#kebab", 0.5, 0.5, 0, 0)
397 var onKebab bool
398 if err := chromedp.Run(ctx, chromedp.Evaluate(fmt.Sprintf(`!!document.elementFromPoint(%v, %v).closest("#kebab")`, kebab.X, kebab.Y), &onKebab)); err != nil {
399 t.Fatal(err)
400 }
401 if !onKebab {
402 t.Errorf("%s: over the kebab the element under the pointer is %s, outside the summary", leg.name, kebab.Hit)
403 }
404 clickAndStay(t, ctx, kebab, "", `document.getElementById("menu").open`)
405 })
406 }
407}
408
409// TestMediaGridSelectionReachesTheBulkBarsForm is the no-JavaScript
410// round trip: with script execution disabled in the engine, a reader
411// ticks a tile, opens the bulk bar's menu and presses an action, and
412// the form posts every checked tile's value under one name beside the
413// action, which is all a handler needs to act on the selection.
414func TestMediaGridSelectionReachesTheBulkBarsForm(t *testing.T) {
415 posted := make(chan url.Values, 4)
416 rig := mediaRig(t, true, mediaMux(t, posted))
417 ctx, cancel := context.WithTimeout(rig.Context(), 120*time.Second)
418 defer cancel()
419 if err := chromedp.Run(ctx, emulation.SetScriptExecutionDisabled(true), chromedp.EmulateViewport(390, 844),
420 chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("[rst-media]", chromedp.ByQuery)); err != nil {
421 t.Fatal(err)
422 }
423 // The page's own inline script marks the document when it runs;
424 // CDP's evaluate runs regardless, so the marker is the proof.
425 if readMedia(t, ctx).Scripted {
426 t.Fatal("the page's script ran; this drive is the no-JavaScript round trip")
427 }
428 // The first tile's box, then the bulk bar's menu and its one action.
429 // Scripts are off, so each is a real click at the control's box.
430 click := func(sel string) {
431 t.Helper()
432 p := probeScripted(t, ctx, false, sel, 0.5, 0.5, 0, 0)
433 if err := chromedp.Run(ctx, chromedp.MouseClickXY(p.X, p.Y)); err != nil {
434 t.Fatal(err)
435 }
436 }
437 click(`[rst-media-tile]:first-child [rst-selbox] input`)
438 click(`[rst-bulkbar] [rst-dropdown] > summary`)
439 click(`[rst-bulkbar] [rst-dropdown-menu] button[value="export"]`)
440 var form url.Values
441 select {
442 case form = <-posted:
443 case <-time.After(10 * time.Second):
444 t.Fatal("the form never posted: the action button did not submit it with scripts off")
445 }
446 select {
447 case again := <-posted:
448 t.Fatalf("the form posted twice; the second time with %v", again)
449 case <-time.After(500 * time.Millisecond):
450 }
451 if got, want := form["id"], []string{"tall", "wide-contain"}; strings.Join(got, ",") != strings.Join(want, ",") {
452 t.Errorf("the selection posted id=%v, want %v: the tile ticked by hand and the one the server checked", got, want)
453 }
454 if got := form.Get("action"); got != "export" {
455 t.Errorf("the action posted is %q, want export", got)
456 }
457}
458