//go:build browser package check import ( "bytes" "context" "fmt" "image" "image/color" "image/draw" "image/png" "math" "strings" "testing" "time" "amadan.net/rastrillo/fiesta" ) // renderingException is a change a 1.x release makes on purpose to how // 1.0 markup draws, which the photographs let through. It covers only // paint that 1.0 clipped away: the band around each matching element, // out to Reach, that falls outside the content box of its Clip ancestor // less Edge pixels, where 1.0 drew the element whole. The element's // inside and everything else are still held to 1.0's pixels, and both // boxes must sit in the same place on both sides, so a change of layout // fails. type renderingException struct { Selector, Clip, Media string Reach, Edge int Why string } // consoleRailRing is the only one. 1.0's console rail nav scrolls, so // it clipped its current item's ring and contact shadow at its padding // box. 1.2.0 gives it 5px of padding and takes the room back with // negative margins, so the content box is where 1.0's padding box was // and the ring now shows past it (Rastrillo cc7c9c69). The rule is // under (min-width: 800px), where the rail is beside the page. The // item's edge is the clip's edge, and the ring's antialiased corner // reaches 2px inside it, where 1.0's clip left it one level apart. var consoleRailRing = &renderingException{ Selector: ".rst-shell-console > .rst-shell__rail > .rst-shell__nav " + "a[aria-current], [rst-shell-console] > [rst-shell-rail] > " + "[rst-shell-nav] a[aria-current]", Clip: ".rst-shell-console > .rst-shell__rail > .rst-shell__nav, " + "[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav]", Media: "(min-width: 800px)", Reach: 5, Edge: 2, Why: "1.2.0 draws the console rail's current item's ring and shadow " + "whole, where 1.0 clipped them at the nav's edges", } // exceptedArea is one element's share of an exception: the pixels of // Outer that are not in Kept. type exceptedArea struct { Outer, Kept image.Rectangle } // areasJS asks the browser for the boxes, because only layout knows // where they ended up. It adds the scroll offset so they are in the // coordinates of the full-page photographs they are compared against. const areasJS = `JSON.stringify(!matchMedia(%s).matches ? [] : [...document.querySelectorAll(%s)].map(element => { const box = element.getBoundingClientRect(); const clip = element.closest(%s); const edges = clip.getBoundingClientRect(); const style = getComputedStyle(clip); const inset = side => parseFloat(style["padding" + side]) + parseFloat(style["border" + side + "Width"]); return { Box: [box.left, box.top, box.right, box.bottom], Clip: [edges.left + inset("Left"), edges.top + inset("Top"), edges.right - inset("Right"), edges.bottom - inset("Bottom")], Width: box.width, Height: box.height, ScrollX: scrollX, ScrollY: scrollY, }; }).filter(found => found.Width > 0 && found.Height > 0))` // areas reads the exception's areas on the settled page, rounding the // outer box outward and the kept box inward, so a pixel a box's edge // cuts through is excepted rather than compared. func (exception *renderingException) areas(t *testing.T, ctx context.Context) []exceptedArea { t.Helper() var found []struct { Box, Clip [4]float64 ScrollX, ScrollY float64 } query := fmt.Sprintf(areasJS, jsStr(exception.Media), jsStr(exception.Selector), jsStr(exception.Clip)) at(t, ctx, query, &found) var out []exceptedArea for _, element := range found { left, top := element.ScrollX, element.ScrollY out = append(out, exceptedArea{ Outer: image.Rect( int(math.Floor(element.Box[0]+left))-exception.Reach, int(math.Floor(element.Box[1]+top))-exception.Reach, int(math.Ceil(element.Box[2]+left))+exception.Reach, int(math.Ceil(element.Box[3]+top))+exception.Reach), Kept: image.Rect( int(math.Ceil(element.Clip[0]+left))+exception.Edge, int(math.Ceil(element.Clip[1]+top))+exception.Edge, int(math.Floor(element.Clip[2]+left))-exception.Edge, int(math.Floor(element.Clip[3]+top))-exception.Edge), }) } return out } // excepted reports whether the pixel at point is one an area lets // differ. func excepted(areas []exceptedArea, point image.Point) bool { for _, area := range areas { if point.In(area.Outer) && !point.In(area.Kept) { return true } } return false } // blanked is picture with every excepted pixel painted one flat // colour, so whatever either side drew there compares equal. func blanked(picture image.Image, areas []exceptedArea) image.Image { if len(areas) == 0 { return picture } out := image.NewRGBA(picture.Bounds()) draw.Draw(out, out.Bounds(), picture, picture.Bounds().Min, draw.Src) magenta := color.RGBA{255, 0, 255, 255} for _, area := range areas { outer := area.Outer.Intersect(out.Bounds()) for y := outer.Min.Y; y < outer.Max.Y; y++ { for x := outer.Min.X; x < outer.Max.X; x++ { if !(image.Point{x, y}).In(area.Kept) { out.Set(x, y, magenta) } } } } return out } // sameAreas says why two sides' excepted areas differ, or "" when they // are the same: an element or a clip that moved is a change of layout, // which no exception lets through. func sameAreas(was, is []exceptedArea) string { if fmt.Sprint(was) != fmt.Sprint(is) { return fmt.Sprintf("the excepted areas moved: %v under 1.0.0's CSS, "+ "%v under the tree's", was, is) } return "" } // samePixelsExcept compares two page shots to the pixel outside the // excepted pixels, after holding the areas to the same place. covered // says whether any excepted pixel differed, so a test can tell an // exception that let a real change through from one that covered // nothing. func samePixelsExcept(t *testing.T, was, is []byte, wasAreas, isAreas []exceptedArea) (ok bool, why string, covered bool) { t.Helper() if why := sameAreas(wasAreas, isAreas); why != "" { return false, why, false } before, after := decodePNG(t, was), decodePNG(t, is) for _, area := range wasAreas { outer := area.Outer.Intersect(before.Bounds()) for y := outer.Min.Y; y < outer.Max.Y && !covered; y++ { for x := outer.Min.X; x < outer.Max.X; x++ { point := image.Point{x, y} if excepted(wasAreas, point) && before.At(x, y) != after.At(x, y) { covered = true break } } } } ok, why = samePixelsWithin(t, encodePNG(t, blanked(before, wasAreas)), encodePNG(t, blanked(after, isAreas)), 0) return ok, why, covered } // encodePNG is picture as PNG bytes, for the comparisons that read them. func encodePNG(t *testing.T, picture image.Image) []byte { t.Helper() var out bytes.Buffer if err := png.Encode(&out, picture); err != nil { t.Fatal(err) } return out.Bytes() } // pageShotExcept is pageShot that also returns the exception's areas // on the settled page. func pageShotExcept(t *testing.T, ctx context.Context, url string, width int64, scheme string, exception *renderingException) ([]byte, []exceptedArea) { t.Helper() var areas []exceptedArea shot := pageShotChecked(t, ctx, url, width, scheme, func() { areas = exception.areas(t, ctx) }) return shot, areas } // TestTheConsoleRingExceptionLetsNothingElseThrough is consoleRailRing's // negative control. The tree's tokens.css must pass and the exception // must cover a change, so the control is not passing by failing // everything. Two tokens.css that change more than the ring must fail: // one without the negative margins that give the nav's padding back, so // the rail's items move 5px, and one that paints the current item's // text another colour, inside the box the exception never covers. func TestTheConsoleRingExceptionLetsNothingElseThrough(t *testing.T) { const giveBack = "margin-block: -5px; margin-inline: -5px; " tokens := string(fiesta.TokensCSS()) if count := strings.Count(tokens, giveBack); count != 1 { t.Fatalf("tokens.css holds %q %d times, want once, in the "+ "console rail's nav rule", giveBack, count) } const recoloured = "\n[rst-shell-nav] a[aria-current] " + "{ color: #c00 !important; }\n" theme, _ := fiesta.ThemeCSS("day") const layout = "layout-console" document := seedFile(t, "examples/"+layout+"/default.html") documents := map[string]string{layout: parityLinks(string(document))} for _, served := range []struct { name string tokens []byte passes bool }{ {"the tree's tokens.css", fiesta.TokensCSS(), true}, {"tokens.css without the margins", []byte(strings.ReplaceAll(tokens, giveBack, "")), false}, {"tokens.css with the current item's text recoloured", []byte(tokens + recoloured), false}, } { old := parityAssets(seedFile(t, "tokens.css"), seedFile(t, "day.css"), seedFile(t, "shell.css")) rig := parityRig(t, documents, old, parityAssets(served.tokens, theme, fiesta.ShellCSS())) ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) before, beforeAreas := pageShotExcept(t, ctx, rig.Origin+"/old/"+layout, 1280, "light", consoleRailRing) after, afterAreas := pageShotExcept(t, ctx, rig.Origin+"/new/"+layout, 1280, "light", consoleRailRing) cancel() if len(beforeAreas) == 0 { t.Fatalf("%s: consoleRailRing covers nothing on 1.0.0's %s "+ "at 1280px; the control proves nothing", served.name, layout) } ok, why, covered := samePixelsExcept(t, before, after, beforeAreas, afterAreas) switch { case served.passes && !ok: t.Errorf("%s: %s fails the comparison: %s", served.name, layout, why) case served.passes && !covered: t.Errorf("%s: consoleRailRing covered no change on %s", served.name, layout) case !served.passes && ok: t.Errorf("%s: the comparison passed; the exception lets more "+ "than the ring through", served.name) default: t.Logf("%s: passes %v %s", served.name, ok, why) } } }