v0.1.0 47aaa0f

assert.go RawHistoryFolder

1package harness
2
3import (
4	"fmt"
5	"image"
6	"image/color"
7	"image/draw"
8	"image/png"
9	"os"
10	"regexp"
11)
12
13// Rect uses half-open pixel coordinates.
14type Rect struct {
15	X      int `json:"x"`
16	Y      int `json:"y"`
17	Width  int `json:"width"`
18	Height int `json:"height"`
19}
20type Check struct {
21	Name   string `json:"name"`
22	Pass   bool   `json:"pass"`
23	Detail string `json:"detail"`
24}
25type Probe struct {
26	Name      string `json:"name"`
27	X         int    `json:"x"`
28	Y         int    `json:"y"`
29	Color     string `json:"color"`
30	Tolerance uint8  `json:"tolerance"`
31}
32type Region struct {
33	Name      string `json:"name"`
34	Rect      Rect   `json:"rect"`
35	Color     string `json:"color,omitempty"`
36	Tolerance uint8  `json:"tolerance,omitempty"`
37}
38type Golden struct {
39	Name         string  `json:"name"`
40	Path         string  `json:"path"`
41	Tolerance    uint8   `json:"tolerance"`
42	MaxDiffRatio float64 `json:"max_diff_ratio"`
43}
44type Expectations struct {
45	Probes  []Probe  `json:"probes"`
46	Regions []Region `json:"regions"`
47	Goldens []Golden `json:"goldens"`
48}
49
50func LoadPNG(path string) (image.Image, error) {
51	f, err := os.Open(path)
52	if err != nil {
53		return nil, err
54	}
55	defer f.Close()
56	return png.Decode(f)
57}
58
59var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
60
61func ParseColor(s string) (color.NRGBA, error) {
62	var r, g, b uint8
63	if !hexColor.MatchString(s) {
64		return color.NRGBA{}, fmt.Errorf("color %q: expected #RRGGBB", s)
65	}
66	if _, err := fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b); err != nil {
67		return color.NRGBA{}, fmt.Errorf("color %q: %w", s, err)
68	}
69	return color.NRGBA{r, g, b, 255}, nil
70}
71func channels(c color.Color) color.NRGBA { return color.NRGBAModel.Convert(c).(color.NRGBA) }
72func closeColor(a, b color.Color, t uint8) bool {
73	x, y := channels(a), channels(b)
74	for _, v := range [][2]uint8{{x.R, y.R}, {x.G, y.G}, {x.B, y.B}, {x.A, y.A}} {
75		d := int(v[0]) - int(v[1])
76		if d < 0 {
77			d = -d
78		}
79		if d > int(t) {
80			return false
81		}
82	}
83	return true
84}
85func BoundsCheck(img image.Image, r Rect) error {
86	b := img.Bounds()
87	if r.Width <= 0 || r.Height <= 0 || r.X < b.Min.X || r.Y < b.Min.Y || r.X > b.Max.X-r.Width || r.Y > b.Max.Y-r.Height {
88		return fmt.Errorf("region %+v outside image %v or empty", r, b)
89	}
90	return nil
91}
92func PixelProbe(img image.Image, x, y int, want color.Color, t uint8) error {
93	if !image.Pt(x, y).In(img.Bounds()) {
94		return fmt.Errorf("pixel (%d,%d) outside %v", x, y, img.Bounds())
95	}
96	got := img.At(x, y)
97	if !closeColor(got, want, t) {
98		return fmt.Errorf("pixel (%d,%d): got %v, want %v (tolerance %d)", x, y, channels(got), channels(want), t)
99	}
100	return nil
101}
102func UniformRegion(img image.Image, r Rect, want color.Color, t uint8) error {
103	if err := BoundsCheck(img, r); err != nil {
104		return err
105	}
106	for y := r.Y; y < r.Y+r.Height; y++ {
107		for x := r.X; x < r.X+r.Width; x++ {
108			if err := PixelProbe(img, x, y, want, t); err != nil {
109				return err
110			}
111		}
112	}
113	return nil
114}
115
116// CompareGolden produces an opaque heatmap for every pixel, even on a mismatch.
117// Pixels within tolerance are black; differing pixels show absolute RGB deltas,
118// with alpha delta in the red channel so alpha-only changes remain visible.
119func CompareGolden(got, want image.Image, t uint8, maxRatio float64) (bool, float64, *image.NRGBA, error) {
120	if maxRatio < 0 || maxRatio > 1 {
121		return false, 0, nil, fmt.Errorf("max_diff_ratio must be between 0 and 1")
122	}
123	if got.Bounds() != want.Bounds() {
124		return false, 0, nil, fmt.Errorf("bounds differ: %v vs %v", got.Bounds(), want.Bounds())
125	}
126	b := got.Bounds()
127	if b.Empty() {
128		return false, 0, nil, fmt.Errorf("empty golden")
129	}
130	diff := image.NewNRGBA(b)
131	draw.Draw(diff, b, &image.Uniform{C: color.NRGBA{0, 0, 0, 255}}, image.Point{}, draw.Src)
132	n := 0
133	for y := b.Min.Y; y < b.Max.Y; y++ {
134		for x := b.Min.X; x < b.Max.X; x++ {
135			a, c := channels(got.At(x, y)), channels(want.At(x, y))
136			if closeColor(a, c, t) {
137				continue
138			}
139			n++
140			delta := func(a, b uint8) uint8 {
141				if a > b {
142					return a - b
143				}
144				return b - a
145			}
146			r := delta(a.R, c.R)
147			if alpha := delta(a.A, c.A); alpha > r {
148				r = alpha
149			}
150			diff.SetNRGBA(x, y, color.NRGBA{r, delta(a.G, c.G), delta(a.B, c.B), 255})
151		}
152	}
153	ratio := float64(n) / float64(b.Dx()*b.Dy())
154	return ratio <= maxRatio, ratio, diff, nil
155}
156func SavePNG(path string, img image.Image) error {
157	f, err := os.Create(path)
158	if err != nil {
159		return err
160	}
161	if err = png.Encode(f, img); err != nil {
162		_ = f.Close()
163		return err
164	}
165	return f.Close()
166}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close