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
3package check
4
5import (
6 "bytes"
7 "context"
8 "fmt"
9 "image"
10 "image/color"
11 "image/draw"
12 "image/png"
13 "math"
14 "strings"
15 "testing"
16 "time"
17
18 "amadan.net/rastrillo/fiesta"
19)
20
21// renderingException is a change a 1.x release makes on purpose to how
22// 1.0 markup draws, which the photographs let through. It covers only
23// paint that 1.0 clipped away: the band around each matching element,
24// out to Reach, that falls outside the content box of its Clip ancestor
25// less Edge pixels, where 1.0 drew the element whole. The element's
26// inside and everything else are still held to 1.0's pixels, and both
27// boxes must sit in the same place on both sides, so a change of layout
28// fails.
29type renderingException struct {
30 Selector, Clip, Media string
31 Reach, Edge int
32 Why string
33}
34
35// consoleRailRing is the only one. 1.0's console rail nav scrolls, so
36// it clipped its current item's ring and contact shadow at its padding
37// box. 1.2.0 gives it 5px of padding and takes the room back with
38// negative margins, so the content box is where 1.0's padding box was
39// and the ring now shows past it (Rastrillo cc7c9c69). The rule is
40// under (min-width: 800px), where the rail is beside the page. The
41// item's edge is the clip's edge, and the ring's antialiased corner
42// reaches 2px inside it, where 1.0's clip left it one level apart.
43var consoleRailRing = &renderingException{
44 Selector: ".rst-shell-console > .rst-shell__rail > .rst-shell__nav " +
45 "a[aria-current], [rst-shell-console] > [rst-shell-rail] > " +
46 "[rst-shell-nav] a[aria-current]",
47 Clip: ".rst-shell-console > .rst-shell__rail > .rst-shell__nav, " +
48 "[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav]",
49 Media: "(min-width: 800px)",
50 Reach: 5,
51 Edge: 2,
52 Why: "1.2.0 draws the console rail's current item's ring and shadow " +
53 "whole, where 1.0 clipped them at the nav's edges",
54}
55
56// exceptedArea is one element's share of an exception: the pixels of
57// Outer that are not in Kept.
58type exceptedArea struct {
59 Outer, Kept image.Rectangle
60}
61
62// areasJS asks the browser for the boxes, because only layout knows
63// where they ended up. It adds the scroll offset so they are in the
64// coordinates of the full-page photographs they are compared against.
65const areasJS = `JSON.stringify(!matchMedia(%s).matches ? [] :
66 [...document.querySelectorAll(%s)].map(element => {
67 const box = element.getBoundingClientRect();
68 const clip = element.closest(%s);
69 const edges = clip.getBoundingClientRect();
70 const style = getComputedStyle(clip);
71 const inset = side => parseFloat(style["padding" + side]) +
72 parseFloat(style["border" + side + "Width"]);
73 return {
74 Box: [box.left, box.top, box.right, box.bottom],
75 Clip: [edges.left + inset("Left"), edges.top + inset("Top"),
76 edges.right - inset("Right"), edges.bottom - inset("Bottom")],
77 Width: box.width, Height: box.height,
78 ScrollX: scrollX, ScrollY: scrollY,
79 };
80 }).filter(found => found.Width > 0 && found.Height > 0))`
81
82// areas reads the exception's areas on the settled page, rounding the
83// outer box outward and the kept box inward, so a pixel a box's edge
84// cuts through is excepted rather than compared.
85func (exception *renderingException) areas(t *testing.T,
86 ctx context.Context) []exceptedArea {
87 t.Helper()
88 var found []struct {
89 Box, Clip [4]float64
90 ScrollX, ScrollY float64
91 }
92 query := fmt.Sprintf(areasJS, jsStr(exception.Media),
93 jsStr(exception.Selector), jsStr(exception.Clip))
94 at(t, ctx, query, &found)
95 var out []exceptedArea
96 for _, element := range found {
97 left, top := element.ScrollX, element.ScrollY
98 out = append(out, exceptedArea{
99 Outer: image.Rect(
100 int(math.Floor(element.Box[0]+left))-exception.Reach,
101 int(math.Floor(element.Box[1]+top))-exception.Reach,
102 int(math.Ceil(element.Box[2]+left))+exception.Reach,
103 int(math.Ceil(element.Box[3]+top))+exception.Reach),
104 Kept: image.Rect(
105 int(math.Ceil(element.Clip[0]+left))+exception.Edge,
106 int(math.Ceil(element.Clip[1]+top))+exception.Edge,
107 int(math.Floor(element.Clip[2]+left))-exception.Edge,
108 int(math.Floor(element.Clip[3]+top))-exception.Edge),
109 })
110 }
111 return out
112}
113
114// excepted reports whether the pixel at point is one an area lets
115// differ.
116func excepted(areas []exceptedArea, point image.Point) bool {
117 for _, area := range areas {
118 if point.In(area.Outer) && !point.In(area.Kept) {
119 return true
120 }
121 }
122 return false
123}
124
125// blanked is picture with every excepted pixel painted one flat
126// colour, so whatever either side drew there compares equal.
127func blanked(picture image.Image, areas []exceptedArea) image.Image {
128 if len(areas) == 0 {
129 return picture
130 }
131 out := image.NewRGBA(picture.Bounds())
132 draw.Draw(out, out.Bounds(), picture, picture.Bounds().Min, draw.Src)
133 magenta := color.RGBA{255, 0, 255, 255}
134 for _, area := range areas {
135 outer := area.Outer.Intersect(out.Bounds())
136 for y := outer.Min.Y; y < outer.Max.Y; y++ {
137 for x := outer.Min.X; x < outer.Max.X; x++ {
138 if !(image.Point{x, y}).In(area.Kept) {
139 out.Set(x, y, magenta)
140 }
141 }
142 }
143 }
144 return out
145}
146
147// sameAreas says why two sides' excepted areas differ, or "" when they
148// are the same: an element or a clip that moved is a change of layout,
149// which no exception lets through.
150func sameAreas(was, is []exceptedArea) string {
151 if fmt.Sprint(was) != fmt.Sprint(is) {
152 return fmt.Sprintf("the excepted areas moved: %v under 1.0.0's CSS, "+
153 "%v under the tree's", was, is)
154 }
155 return ""
156}
157
158// samePixelsExcept compares two page shots to the pixel outside the
159// excepted pixels, after holding the areas to the same place. covered
160// says whether any excepted pixel differed, so a test can tell an
161// exception that let a real change through from one that covered
162// nothing.
163func samePixelsExcept(t *testing.T, was, is []byte,
164 wasAreas, isAreas []exceptedArea) (ok bool, why string, covered bool) {
165 t.Helper()
166 if why := sameAreas(wasAreas, isAreas); why != "" {
167 return false, why, false
168 }
169 before, after := decodePNG(t, was), decodePNG(t, is)
170 for _, area := range wasAreas {
171 outer := area.Outer.Intersect(before.Bounds())
172 for y := outer.Min.Y; y < outer.Max.Y && !covered; y++ {
173 for x := outer.Min.X; x < outer.Max.X; x++ {
174 point := image.Point{x, y}
175 if excepted(wasAreas, point) &&
176 before.At(x, y) != after.At(x, y) {
177 covered = true
178 break
179 }
180 }
181 }
182 }
183 ok, why = samePixelsWithin(t, encodePNG(t, blanked(before, wasAreas)),
184 encodePNG(t, blanked(after, isAreas)), 0)
185 return ok, why, covered
186}
187
188// encodePNG is picture as PNG bytes, for the comparisons that read them.
189func encodePNG(t *testing.T, picture image.Image) []byte {
190 t.Helper()
191 var out bytes.Buffer
192 if err := png.Encode(&out, picture); err != nil {
193 t.Fatal(err)
194 }
195 return out.Bytes()
196}
197
198// pageShotExcept is pageShot that also returns the exception's areas
199// on the settled page.
200func pageShotExcept(t *testing.T, ctx context.Context, url string,
201 width int64, scheme string,
202 exception *renderingException) ([]byte, []exceptedArea) {
203 t.Helper()
204 var areas []exceptedArea
205 shot := pageShotChecked(t, ctx, url, width, scheme, func() {
206 areas = exception.areas(t, ctx)
207 })
208 return shot, areas
209}
210
211// TestTheConsoleRingExceptionLetsNothingElseThrough is consoleRailRing's
212// negative control. The tree's tokens.css must pass and the exception
213// must cover a change, so the control is not passing by failing
214// everything. Two tokens.css that change more than the ring must fail:
215// one without the negative margins that give the nav's padding back, so
216// the rail's items move 5px, and one that paints the current item's
217// text another colour, inside the box the exception never covers.
218func TestTheConsoleRingExceptionLetsNothingElseThrough(t *testing.T) {
219 const giveBack = "margin-block: -5px; margin-inline: -5px; "
220 tokens := string(fiesta.TokensCSS())
221 if count := strings.Count(tokens, giveBack); count != 1 {
222 t.Fatalf("tokens.css holds %q %d times, want once, in the "+
223 "console rail's nav rule", giveBack, count)
224 }
225 const recoloured = "\n[rst-shell-nav] a[aria-current] " +
226 "{ color: #c00 !important; }\n"
227 theme, _ := fiesta.ThemeCSS("day")
228 const layout = "layout-console"
229 document := seedFile(t, "examples/"+layout+"/default.html")
230 documents := map[string]string{layout: parityLinks(string(document))}
231 for _, served := range []struct {
232 name string
233 tokens []byte
234 passes bool
235 }{
236 {"the tree's tokens.css", fiesta.TokensCSS(), true},
237 {"tokens.css without the margins",
238 []byte(strings.ReplaceAll(tokens, giveBack, "")), false},
239 {"tokens.css with the current item's text recoloured",
240 []byte(tokens + recoloured), false},
241 } {
242 old := parityAssets(seedFile(t, "tokens.css"),
243 seedFile(t, "day.css"), seedFile(t, "shell.css"))
244 rig := parityRig(t, documents, old,
245 parityAssets(served.tokens, theme, fiesta.ShellCSS()))
246 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
247 before, beforeAreas := pageShotExcept(t, ctx,
248 rig.Origin+"/old/"+layout, 1280, "light", consoleRailRing)
249 after, afterAreas := pageShotExcept(t, ctx,
250 rig.Origin+"/new/"+layout, 1280, "light", consoleRailRing)
251 cancel()
252 if len(beforeAreas) == 0 {
253 t.Fatalf("%s: consoleRailRing covers nothing on 1.0.0's %s "+
254 "at 1280px; the control proves nothing", served.name, layout)
255 }
256 ok, why, covered := samePixelsExcept(t, before, after, beforeAreas,
257 afterAreas)
258 switch {
259 case served.passes && !ok:
260 t.Errorf("%s: %s fails the comparison: %s", served.name, layout,
261 why)
262 case served.passes && !covered:
263 t.Errorf("%s: consoleRailRing covered no change on %s",
264 served.name, layout)
265 case !served.passes && ok:
266 t.Errorf("%s: the comparison passed; the exception lets more "+
267 "than the ring through", served.name)
268 default:
269 t.Logf("%s: passes %v %s", served.name, ok, why)
270 }
271 }
272}
273