|
| 1 | +package canvas |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "image/color" |
| 6 | + "testing" |
| 7 | +) |
| 8 | + |
| 9 | +func TestGradAdd(t *testing.T) { |
| 10 | + red := color.RGBA{255, 0, 0, 255} |
| 11 | + green := color.RGBA{0, 255, 0, 255} |
| 12 | + blue := color.RGBA{0, 0, 255, 255} |
| 13 | + white := color.RGBA{255, 255, 255, 255} |
| 14 | + |
| 15 | + tests := []struct { |
| 16 | + name string |
| 17 | + initial Grad |
| 18 | + addT float64 |
| 19 | + addColor color.RGBA |
| 20 | + want Grad |
| 21 | + }{ |
| 22 | + { |
| 23 | + name: "add to empty gradient", |
| 24 | + initial: Grad{}, |
| 25 | + addT: 0.5, |
| 26 | + addColor: red, |
| 27 | + want: Grad{{0.5, red}}, |
| 28 | + }, |
| 29 | + { |
| 30 | + name: "add at end", |
| 31 | + initial: Grad{{0.0, red}}, |
| 32 | + addT: 1.0, |
| 33 | + addColor: blue, |
| 34 | + want: Grad{{0.0, red}, {1.0, blue}}, |
| 35 | + }, |
| 36 | + { |
| 37 | + name: "add at beginning", |
| 38 | + initial: Grad{{0.5, red}}, |
| 39 | + addT: 0.0, |
| 40 | + addColor: blue, |
| 41 | + want: Grad{{0.0, blue}, {0.5, red}}, |
| 42 | + }, |
| 43 | + { |
| 44 | + name: "insert in middle maintains sort order", |
| 45 | + initial: Grad{{0.0, red}, {1.0, blue}}, |
| 46 | + addT: 0.5, |
| 47 | + addColor: green, |
| 48 | + want: Grad{{0.0, red}, {0.5, green}, {1.0, blue}}, |
| 49 | + }, |
| 50 | + { |
| 51 | + name: "replace existing offset", |
| 52 | + initial: Grad{{0.0, red}, {0.5, green}, {1.0, blue}}, |
| 53 | + addT: 0.5, |
| 54 | + addColor: white, |
| 55 | + want: Grad{{0.0, red}, {0.5, white}, {1.0, blue}}, |
| 56 | + }, |
| 57 | + { |
| 58 | + name: "clamp t below 0", |
| 59 | + initial: Grad{}, |
| 60 | + addT: -0.5, |
| 61 | + addColor: red, |
| 62 | + want: Grad{{0.0, red}}, |
| 63 | + }, |
| 64 | + { |
| 65 | + name: "clamp t above 1", |
| 66 | + initial: Grad{}, |
| 67 | + addT: 1.5, |
| 68 | + addColor: red, |
| 69 | + want: Grad{{1.0, red}}, |
| 70 | + }, |
| 71 | + { |
| 72 | + name: "add multiple maintains order", |
| 73 | + initial: Grad{{0.2, red}, {0.8, blue}}, |
| 74 | + addT: 0.4, |
| 75 | + addColor: green, |
| 76 | + want: Grad{{0.2, red}, {0.4, green}, {0.8, blue}}, |
| 77 | + }, |
| 78 | + { |
| 79 | + name: "add semi-transparent color clips to premultiplied", |
| 80 | + initial: Grad{}, |
| 81 | + addT: 0.5, |
| 82 | + addColor: color.RGBA{255, 0, 0, 128}, |
| 83 | + want: Grad{{0.5, color.RGBA{128, 0, 0, 128}}}, |
| 84 | + }, |
| 85 | + { |
| 86 | + name: "replace opaque with semi-transparent clips to premultiplied", |
| 87 | + initial: Grad{{0.0, red}, {0.5, green}, {1.0, blue}}, |
| 88 | + addT: 0.5, |
| 89 | + addColor: color.RGBA{0, 255, 0, 64}, |
| 90 | + want: Grad{{0.0, red}, {0.5, color.RGBA{0, 64, 0, 64}}, {1.0, blue}}, |
| 91 | + }, |
| 92 | + { |
| 93 | + name: "fully transparent color", |
| 94 | + initial: Grad{{0.0, red}}, |
| 95 | + addT: 1.0, |
| 96 | + addColor: color.RGBA{0, 0, 0, 0}, |
| 97 | + want: Grad{{0.0, red}, {1.0, color.RGBA{0, 0, 0, 0}}}, |
| 98 | + }, |
| 99 | + { |
| 100 | + name: "mixed transparent stops maintain order", |
| 101 | + initial: Grad{{0.0, color.RGBA{255, 0, 0, 200}}, {1.0, color.RGBA{0, 0, 255, 100}}}, |
| 102 | + addT: 0.5, |
| 103 | + addColor: color.RGBA{0, 255, 0, 50}, |
| 104 | + want: Grad{{0.0, color.RGBA{255, 0, 0, 200}}, {0.5, color.RGBA{0, 50, 0, 50}}, {1.0, color.RGBA{0, 0, 255, 100}}}, |
| 105 | + }, |
| 106 | + } |
| 107 | + |
| 108 | + for _, tt := range tests { |
| 109 | + t.Run(tt.name, func(t *testing.T) { |
| 110 | + g := make(Grad, len(tt.initial)) |
| 111 | + copy(g, tt.initial) |
| 112 | + g.Add(tt.addT, tt.addColor) |
| 113 | + |
| 114 | + if len(g) != len(tt.want) { |
| 115 | + t.Fatalf("got %d stops, want %d", len(g), len(tt.want)) |
| 116 | + } |
| 117 | + for i := range tt.want { |
| 118 | + if !Equal(g[i].Offset, tt.want[i].Offset) { |
| 119 | + t.Errorf("stop[%d].Offset = %v, want %v", i, g[i].Offset, tt.want[i].Offset) |
| 120 | + } |
| 121 | + if g[i].Color != tt.want[i].Color { |
| 122 | + t.Errorf("stop[%d].Color = %v, want %v", i, g[i].Color, tt.want[i].Color) |
| 123 | + } |
| 124 | + } |
| 125 | + }) |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +func TestRadialGradientAt(t *testing.T) { |
| 130 | + red := color.RGBA{255, 0, 0, 255} |
| 131 | + blue := color.RGBA{0, 0, 255, 255} |
| 132 | + |
| 133 | + // Helper to build a radial gradient with red at t=0 and blue at t=1. |
| 134 | + makeGrad := func(c0 Point, r0 float64, c1 Point, r1 float64) *RadialGradient { |
| 135 | + g := NewRadialGradient(c0, r0, c1, r1) |
| 136 | + g.Add(0.0, red) |
| 137 | + g.Add(1.0, blue) |
| 138 | + return g |
| 139 | + } |
| 140 | + |
| 141 | + // colorApproxEqual allows small rounding differences from interpolation. |
| 142 | + colorApproxEqual := func(a, b color.RGBA) bool { |
| 143 | + diff := func(x, y uint8) int { |
| 144 | + d := int(x) - int(y) |
| 145 | + if d < 0 { |
| 146 | + return -d |
| 147 | + } |
| 148 | + return d |
| 149 | + } |
| 150 | + return diff(a.R, b.R) <= 1 && diff(a.G, b.G) <= 1 && diff(a.B, b.B) <= 1 && diff(a.A, b.A) <= 1 |
| 151 | + } |
| 152 | + |
| 153 | + tests := []struct { |
| 154 | + name string |
| 155 | + grad *RadialGradient |
| 156 | + x, y float64 |
| 157 | + want color.RGBA |
| 158 | + }{ |
| 159 | + { |
| 160 | + name: "empty gradient returns transparent", |
| 161 | + grad: NewRadialGradient(Point{0, 0}, 0, Point{0, 0}, 10), |
| 162 | + x: 5, |
| 163 | + y: 0, |
| 164 | + want: Transparent, |
| 165 | + }, |
| 166 | + { |
| 167 | + name: "concentric at center returns first stop", |
| 168 | + grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10), |
| 169 | + x: 0, |
| 170 | + y: 0, |
| 171 | + want: red, |
| 172 | + }, |
| 173 | + { |
| 174 | + name: "concentric at outer edge returns last stop", |
| 175 | + grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10), |
| 176 | + x: 10, |
| 177 | + y: 0, |
| 178 | + want: blue, |
| 179 | + }, |
| 180 | + { |
| 181 | + name: "concentric beyond outer edge clamps to last stop", |
| 182 | + grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10), |
| 183 | + x: 20, |
| 184 | + y: 0, |
| 185 | + want: blue, |
| 186 | + }, |
| 187 | + { |
| 188 | + name: "concentric at midpoint interpolates", |
| 189 | + grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10), |
| 190 | + x: 5, |
| 191 | + y: 0, |
| 192 | + want: color.RGBA{128, 0, 127, 255}, |
| 193 | + }, |
| 194 | + { |
| 195 | + name: "concentric along y axis", |
| 196 | + grad: makeGrad(Point{0, 0}, 0, Point{0, 0}, 10), |
| 197 | + x: 0, |
| 198 | + y: 10, |
| 199 | + want: blue, |
| 200 | + }, |
| 201 | + { |
| 202 | + name: "concentric with offset center", |
| 203 | + grad: makeGrad(Point{5, 5}, 0, Point{5, 5}, 10), |
| 204 | + x: 5, |
| 205 | + y: 5, |
| 206 | + want: red, |
| 207 | + }, |
| 208 | + { |
| 209 | + name: "concentric with offset center at edge", |
| 210 | + grad: makeGrad(Point{5, 5}, 0, Point{5, 5}, 10), |
| 211 | + x: 15, |
| 212 | + y: 5, |
| 213 | + want: blue, |
| 214 | + }, |
| 215 | + { |
| 216 | + name: "non-concentric gradient at inner center", |
| 217 | + grad: makeGrad(Point{0, 0}, 0, Point{10, 0}, 10), |
| 218 | + x: 0, |
| 219 | + y: 0, |
| 220 | + want: red, |
| 221 | + }, |
| 222 | + { |
| 223 | + name: "single stop gradient returns that stop everywhere", |
| 224 | + grad: func() *RadialGradient { |
| 225 | + g := NewRadialGradient(Point{0, 0}, 0, Point{0, 0}, 10) |
| 226 | + g.Add(0.0, red) |
| 227 | + return g |
| 228 | + }(), |
| 229 | + x: 5, |
| 230 | + y: 0, |
| 231 | + want: red, |
| 232 | + }, |
| 233 | + { |
| 234 | + name: "offset centers uses valid root not largest root", |
| 235 | + grad: func() *RadialGradient { |
| 236 | + g := NewRadialGradient(Point{30, 30}, 5, Point{170, 170}, 60) |
| 237 | + g.Add(0.0, color.RGBA{255, 192, 203, 255}) |
| 238 | + g.Add(0.5, color.RGBA{255, 255, 255, 255}) |
| 239 | + g.Add(1.0, color.RGBA{0, 128, 0, 255}) |
| 240 | + return g |
| 241 | + }(), |
| 242 | + x: 160, |
| 243 | + y: 111, |
| 244 | + want: color.RGBA{180, 218, 180, 255}, |
| 245 | + }, |
| 246 | + { |
| 247 | + name: "offset centers near outer boundary not clamped to last stop", |
| 248 | + grad: func() *RadialGradient { |
| 249 | + g := NewRadialGradient(Point{30, 30}, 5, Point{170, 170}, 60) |
| 250 | + g.Add(0.0, color.RGBA{255, 192, 203, 255}) |
| 251 | + g.Add(0.5, color.RGBA{255, 255, 255, 255}) |
| 252 | + g.Add(1.0, color.RGBA{0, 128, 0, 255}) |
| 253 | + return g |
| 254 | + }(), |
| 255 | + x: 165, |
| 256 | + y: 111, |
| 257 | + want: color.RGBA{164, 210, 164, 255}, |
| 258 | + }, |
| 259 | + { |
| 260 | + name: "concentric with semi-transparent stops", |
| 261 | + grad: func() *RadialGradient { |
| 262 | + g := NewRadialGradient(Point{0, 0}, 0, Point{0, 0}, 10) |
| 263 | + g.Add(0.0, color.RGBA{128, 0, 0, 128}) |
| 264 | + g.Add(1.0, color.RGBA{0, 0, 128, 128}) |
| 265 | + return g |
| 266 | + }(), |
| 267 | + x: 0, |
| 268 | + y: 0, |
| 269 | + want: color.RGBA{128, 0, 0, 128}, |
| 270 | + }, |
| 271 | + } |
| 272 | + |
| 273 | + for _, tt := range tests { |
| 274 | + t.Run(tt.name, func(t *testing.T) { |
| 275 | + got := tt.grad.At(tt.x, tt.y) |
| 276 | + if !colorApproxEqual(got, tt.want) { |
| 277 | + t.Errorf("At(%v, %v) = %v, want %v (within ±1 per channel)", |
| 278 | + tt.x, tt.y, formatRGBA(got), formatRGBA(tt.want)) |
| 279 | + } |
| 280 | + }) |
| 281 | + } |
| 282 | +} |
| 283 | + |
| 284 | +func formatRGBA(c color.RGBA) string { |
| 285 | + return fmt.Sprintf("RGBA{%d, %d, %d, %d}", c.R, c.G, c.B, c.A) |
| 286 | +} |
0 commit comments