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}