rastrillo / fiesta Public

Except only the ring's band past the console nav's 1.0 edge

The console ring exception blanked the whole current item and its ring, so a change inside the item (its text, an icon's colour, a child that moved) passed. It now blanks only the pixels around the item, out to 5px, that fall outside the nav's content box less 2px, which is where 1.0 clipped the ring. The item's inside and the nav's are compared to the pixel. Both boxes must sit in the same place on both sides.

The exception and its control move to rendering_exception_browser_test.go. TestTheConsoleRingExceptionLetsNothingElseThrough adds a case that recolours the current item's text, which must fail, beside the moved items and the tree's own tokens.css.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 382fb822bdea4cfa6cd8308156f95a517f876803 parent 91ebf82
4 files changed, +264 −174
  • check/matrix_browser_test.go +5 −5
  • check/oneoh_browser_test.go +2 −3
  • check/parity_browser_test.go +0 −166
  • check/rendering_exception_browser_test.go +257 −0
diff --git a/check/matrix_browser_test.go b/check/matrix_browser_test.go
index 80b5cca..3554808 100644
--- a/check/matrix_browser_test.go
+++ b/check/matrix_browser_test.go
@@ -284,14 +284,14 @@ func matrixShots(t *testing.T, ctx context.Context, origin, dir, scheme string)
// matrixShotsExcept is matrixShots with the areas an exception covers
// blanked in every shot before it is cropped, and those areas returned,
// so the caller can hold them to the same place on both sides.
-func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme string, exception *renderingException) (map[string][]byte, []image.Rectangle) {
+func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme string, exception *renderingException) (map[string][]byte, []exceptedArea) {
t.Helper()
mustRun(t, ctx, chromedp.EmulateViewport(1280, 1600), chromedp.Navigate(origin+dir), chromedp.WaitVisible("#field", chromedp.ByQuery),
chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), document.getElementById("dd").open = true, true`, nil))
still(t, ctx)
- var excepted []image.Rectangle
+ var areas []exceptedArea
if exception != nil {
- excepted = exception.areas(t, ctx)
+ areas = exception.areas(t, ctx)
}
out := map[string][]byte{}
for _, states := range matrixStates {
@@ -319,7 +319,7 @@ func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme st
}
var buf []byte
mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100))
- img := blanked(decodePNG(t, buf), excepted)
+ img := blanked(decodePNG(t, buf), areas)
for _, b := range boxes {
out[b.key] = cropPNG(t, img, b.x, b.y, b.w, b.h, matrixMargin)
}
@@ -327,7 +327,7 @@ func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme st
forceState(t, ctx, force)
}
}
- return out, excepted
+ return out, areas
}
// TestEveryThemeRendersAsItDidExceptDay is a preservation test: it does
diff --git a/check/oneoh_browser_test.go b/check/oneoh_browser_test.go
index a874fe9..7ebe389 100644
--- a/check/oneoh_browser_test.go
+++ b/check/oneoh_browser_test.go
@@ -6,7 +6,6 @@ import (
"context"
"encoding/json"
"fmt"
- "image"
"io/fs"
"net/http"
"os"
@@ -231,7 +230,7 @@ const oneOhShotBudget = 60 * time.Second
// reads the areas consoleRailRing covers on it. Before the photograph
// every picture has loaded and the rig has seen no failed request or
// response of 400 or more, or the test fails naming them.
-func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc string, leg oneOhLeg) ([]byte, []image.Rectangle) {
+func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc string, leg oneOhLeg) ([]byte, []exceptedArea) {
t.Helper()
url := rig.Origin + "/" + side + "/examples/" + doc
if leg.rtl {
@@ -247,7 +246,7 @@ func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc st
t.Errorf("%s: no answer within %s; the page or the browser hung", where, oneOhShotBudget)
}
}()
- var areas []image.Rectangle
+ var areas []exceptedArea
shot := pageShotChecked(t, ctx, url, leg.width, leg.scheme, func() {
var broken []string
mustRun(t, ctx, chromedp.Evaluate(imagesLoadedJS, &broken, awaitPromise))
diff --git a/check/parity_browser_test.go b/check/parity_browser_test.go
index db51caf..54a7bff 100644
--- a/check/parity_browser_test.go
+++ b/check/parity_browser_test.go
@@ -3,14 +3,8 @@
package check
import (
- "bytes"
"context"
"fmt"
- "image"
- "image/color"
- "image/draw"
- "image/png"
- "math"
"net/http"
"os"
"sort"
@@ -95,119 +89,6 @@ func parityAssets(tokens, theme, shell []byte) map[string][]byte {
return assets
}
-// renderingException is a change a 1.x release makes on purpose to
-// how 1.0 markup draws, which the photographs let through: the elements
-// it covers, the media query under which it applies, how far past their
-// border box it paints, and why. The areas are blanked on both sides
-// and must sit in the same place on both, so an exception lets a change
-// of paint through and never a change of layout; every pixel outside
-// them is still held to 1.0's.
-type renderingException struct {
- Selector, Media string
- Reach int
- Why string
-}
-
-// consoleRailRing is the only one. A nav that scrolls clips at its
-// padding box, so 1.0's console rail cut its current item's ring and
-// contact shadow off at the nav's edges. 1.2.0 gives the nav 5px of
-// padding and takes it back with negative margins, a smaller sticky
-// inset and a larger cap (Rastrillo cc7c9c69), so the ring draws whole
-// and no item moves. The rule is under (min-width: 800px), where the
-// rail is beside the page.
-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]",
- Media: "(min-width: 800px)",
- Reach: 5,
- 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",
-}
-
-// areas reads, on the settled page, the border box of every element the
-// exception covers, grown by its reach, in the page's pixels. Where its
-// media query does not match, the exception covers nothing.
-func (e *renderingException) areas(t *testing.T, ctx context.Context) []image.Rectangle {
- t.Helper()
- var boxes []struct{ X, Y, W, H float64 }
- at(t, ctx, `JSON.stringify(!matchMedia(`+jsStr(e.Media)+`).matches ? [] :
- [...document.querySelectorAll(`+jsStr(e.Selector)+`)].map(element => {
- const box = element.getBoundingClientRect();
- return {X: box.left + scrollX, Y: box.top + scrollY, W: box.width, H: box.height};
- }).filter(box => box.W > 0 && box.H > 0))`, &boxes)
- var out []image.Rectangle
- for _, box := range boxes {
- out = append(out, image.Rect(
- int(math.Floor(box.X))-e.Reach, int(math.Floor(box.Y))-e.Reach,
- int(math.Ceil(box.X+box.W))+e.Reach, int(math.Ceil(box.Y+box.H))+e.Reach))
- }
- return out
-}
-
-// blanked is img with every area painted one flat colour, so whatever
-// either side drew there compares equal.
-func blanked(img image.Image, areas []image.Rectangle) image.Image {
- if len(areas) == 0 {
- return img
- }
- out := image.NewRGBA(img.Bounds())
- draw.Draw(out, out.Bounds(), img, img.Bounds().Min, draw.Src)
- for _, area := range areas {
- draw.Draw(out, area.Intersect(out.Bounds()), image.NewUniform(color.RGBA{255, 0, 255, 255}), image.Point{}, draw.Src)
- }
- return out
-}
-
-// sameAreas says why two sides' excepted areas differ, or "" when they
-// are the same: an excepted element that moved is a change of layout,
-// which no exception lets through.
-func sameAreas(was, is []image.Rectangle) 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 with the
-// excepted areas blanked on both, after holding the areas to the same
-// place. covered says whether anything inside them 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 []image.Rectangle) (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 {
- if ok, _ := samePixelsWithin(t, cropPNG(t, before, float64(area.Min.X), float64(area.Min.Y), float64(area.Dx()), float64(area.Dy()), 0),
- cropPNG(t, after, float64(area.Min.X), float64(area.Min.Y), float64(area.Dx()), float64(area.Dy()), 0), 0); !ok {
- covered = true
- }
- }
- ok, why = samePixelsWithin(t, encodePNG(t, blanked(before, wasAreas)), encodePNG(t, blanked(after, isAreas)), 0)
- return ok, why, covered
-}
-
-// encodePNG is img as PNG bytes, for the comparisons that read them.
-func encodePNG(t *testing.T, img image.Image) []byte {
- t.Helper()
- var out bytes.Buffer
- if err := png.Encode(&out, img); err != nil {
- t.Fatal(err)
- }
- return out.Bytes()
-}
-
-// pageShotExcept is pageShot that also returns the areas exception
-// covers on the settled page.
-func pageShotExcept(t *testing.T, ctx context.Context, url string, width int64, scheme string, exception *renderingException) ([]byte, []image.Rectangle) {
- t.Helper()
- var areas []image.Rectangle
- shot := pageShotChecked(t, ctx, url, width, scheme, func() { areas = exception.areas(t, ctx) })
- return shot, areas
-}
-
// seedFile reads one file of the synced baseline, testdata/seed/.
func seedFile(t *testing.T, name string) []byte {
t.Helper()
@@ -322,50 +203,3 @@ func TestEveryThemeAndShellRendersAsTheBaseline(t *testing.T) {
})
}
}
-
-// TestTheConsoleRingExceptionLetsNoLayoutThrough is consoleRailRing's
-// negative control. Served the tree's tokens.css without the negative
-// margins that give the nav's new padding back, the console rail's items
-// move 5px, and the comparison must fail on the move however the blanked
-// areas compare. The tree's own tokens.css must pass, covering a change,
-// so the control is not passing by failing everything.
-func TestTheConsoleRingExceptionLetsNoLayoutThrough(t *testing.T) {
- const giveBack = "margin-block: -5px; margin-inline: -5px; "
- tokens := string(fiesta.TokensCSS())
- if n := strings.Count(tokens, giveBack); n != 1 {
- t.Fatalf("tokens.css holds %q %d times, want once, in the console rail's nav rule", giveBack, n)
- }
- moved := []byte(strings.ReplaceAll(tokens, giveBack, ""))
- theme, _ := fiesta.ThemeCSS("day")
- const layout = "layout-console"
- docs := map[string]string{layout: parityLinks(string(seedFile(t, "examples/"+layout+"/default.html")))}
- for _, c := range []struct {
- name string
- tokens []byte
- passes bool
- }{
- {"the tree's tokens.css", fiesta.TokensCSS(), true},
- {"tokens.css without the margins", moved, false},
- } {
- old := parityAssets(seedFile(t, "tokens.css"), seedFile(t, "day.css"), seedFile(t, "shell.css"))
- rig := parityRig(t, docs, old, parityAssets(c.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", c.name, layout)
- }
- ok, why, covered := samePixelsExcept(t, before, after, beforeAreas, afterAreas)
- switch {
- case c.passes && !ok:
- t.Errorf("%s: %s fails the comparison: %s", c.name, layout, why)
- case c.passes && !covered:
- t.Errorf("%s: consoleRailRing covered no change on %s", c.name, layout)
- case !c.passes && ok:
- t.Errorf("%s: the console rail's items moved and the comparison passed; the exception lets layout through", c.name)
- default:
- t.Logf("%s: %v %s", c.name, ok, why)
- }
- }
-}
diff --git a/check/rendering_exception_browser_test.go b/check/rendering_exception_browser_test.go
new file mode 100644
index 0000000..b246de3
--- /dev/null
+++ b/check/rendering_exception_browser_test.go
@@ -0,0 +1,257 @@
+//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 reads, for each element an exception covers, its border box
+// and its clip's content box, in page coordinates.
+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)
+ }
+ }
+}