| 1 | //go:build browser |
| 2 | |
| 3 | package check |
| 4 | |
| 5 | import ( |
| 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. |
| 29 | type 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. |
| 43 | var 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. |
| 58 | type 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. |
| 65 | const 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. |
| 85 | func (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. |
| 116 | func 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. |
| 127 | func 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. |
| 150 | func 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. |
| 163 | func 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. |
| 189 | func 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. |
| 200 | func 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. |
| 218 | func 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 | |