| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/binary" |
| 6 | "fmt" |
| 7 | "image" |
| 8 | "image/color" |
| 9 | "image/png" |
| 10 | "os" |
| 11 | "testing" |
| 12 | ) |
| 13 | |
| 14 | func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) { |
| 15 | f, err := os.Open("build/appicon.png") |
| 16 | if err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | defer f.Close() |
| 20 | |
| 21 | img, err := png.Decode(f) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | |
| 26 | assertFullCanvasRoundedIcon(t, img, 1024) |
| 27 | } |
| 28 | |
| 29 | func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) { |
| 30 | for _, size := range []int{16, 24, 32, 48, 64, 256} { |
| 31 | t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) { |
| 32 | img := decodeICOImage(t, "build/windows/icon.ico", size) |
| 33 | assertFullCanvasRoundedIcon(t, img, size) |
| 34 | }) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestDarwinICNSUsesMacOSIconSafeArea(t *testing.T) { |
| 39 | img := decodeICNSImage(t, "build/darwin/icon.icns", "ic10") |
| 40 | if got, want := img.Bounds(), image.Rect(0, 0, 1024, 1024); got != want { |
| 41 | t.Fatalf("macOS ic10 frame bounds = %v, want %v", got, want) |
| 42 | } |
| 43 | if got, want := alphaBounds(img), image.Rect(100, 100, 924, 924); got != want { |
| 44 | t.Fatalf("macOS icon visible bounds = %v, want %v", got, want) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) { |
| 49 | t.Helper() |
| 50 | |
| 51 | bounds := img.Bounds() |
| 52 | if bounds.Dx() != size || bounds.Dy() != size { |
| 53 | t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy()) |
| 54 | } |
| 55 | |
| 56 | corners := []struct { |
| 57 | name string |
| 58 | x int |
| 59 | y int |
| 60 | }{ |
| 61 | {"top-left", bounds.Min.X, bounds.Min.Y}, |
| 62 | {"top-right", bounds.Max.X - 1, bounds.Min.Y}, |
| 63 | {"bottom-left", bounds.Min.X, bounds.Max.Y - 1}, |
| 64 | {"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1}, |
| 65 | } |
| 66 | for _, corner := range corners { |
| 67 | _, _, _, a := img.At(corner.x, corner.y).RGBA() |
| 68 | if a > 0xff { |
| 69 | t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | _, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA() |
| 74 | if centerAlpha == 0 { |
| 75 | t.Fatal("app icon center must contain visible artwork") |
| 76 | } |
| 77 | |
| 78 | edgePoints := []struct { |
| 79 | name string |
| 80 | x int |
| 81 | y int |
| 82 | }{ |
| 83 | {"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y}, |
| 84 | {"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2}, |
| 85 | {"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1}, |
| 86 | {"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2}, |
| 87 | } |
| 88 | for _, point := range edgePoints { |
| 89 | _, _, _, a := img.At(point.x, point.y).RGBA() |
| 90 | if a == 0 { |
| 91 | t.Fatalf("%s edge must contain visible rounded-rect background", point.name) |
| 92 | } |
| 93 | assertReasonixBlue(t, point.name, img.At(point.x, point.y)) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) { |
| 98 | t.Helper() |
| 99 | |
| 100 | r16, g16, b16, _ := colorValue.RGBA() |
| 101 | r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8) |
| 102 | if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) { |
| 103 | t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func near(got, want uint8, tolerance uint8) bool { |
| 108 | if got > want { |
| 109 | return got-want <= tolerance |
| 110 | } |
| 111 | return want-got <= tolerance |
| 112 | } |
| 113 | |
| 114 | func alphaBounds(img image.Image) image.Rectangle { |
| 115 | bounds := img.Bounds() |
| 116 | visible := image.Rectangle{} |
| 117 | found := false |
| 118 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { |
| 119 | for x := bounds.Min.X; x < bounds.Max.X; x++ { |
| 120 | _, _, _, a := img.At(x, y).RGBA() |
| 121 | if a == 0 { |
| 122 | continue |
| 123 | } |
| 124 | if !found { |
| 125 | visible = image.Rect(x, y, x+1, y+1) |
| 126 | found = true |
| 127 | continue |
| 128 | } |
| 129 | if x < visible.Min.X { |
| 130 | visible.Min.X = x |
| 131 | } |
| 132 | if y < visible.Min.Y { |
| 133 | visible.Min.Y = y |
| 134 | } |
| 135 | if x >= visible.Max.X { |
| 136 | visible.Max.X = x + 1 |
| 137 | } |
| 138 | if y >= visible.Max.Y { |
| 139 | visible.Max.Y = y + 1 |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | return visible |
| 144 | } |
| 145 | |
| 146 | func decodeICNSImage(t *testing.T, path, iconType string) image.Image { |
| 147 | t.Helper() |
| 148 | |
| 149 | data, err := os.ReadFile(path) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | if len(data) < 8 || string(data[:4]) != "icns" { |
| 154 | t.Fatal("invalid ICNS header") |
| 155 | } |
| 156 | if declaredSize := int(binary.BigEndian.Uint32(data[4:8])); declaredSize != len(data) { |
| 157 | t.Fatalf("ICNS size = %d, want %d", declaredSize, len(data)) |
| 158 | } |
| 159 | |
| 160 | for offset := 8; offset < len(data); { |
| 161 | if offset+8 > len(data) { |
| 162 | t.Fatalf("truncated ICNS entry header at offset %d", offset) |
| 163 | } |
| 164 | entryType := string(data[offset : offset+4]) |
| 165 | entrySize := int(binary.BigEndian.Uint32(data[offset+4 : offset+8])) |
| 166 | if entrySize < 8 || offset+entrySize > len(data) { |
| 167 | t.Fatalf("invalid ICNS entry %q size %d at offset %d", entryType, entrySize, offset) |
| 168 | } |
| 169 | if entryType == iconType { |
| 170 | img, err := png.Decode(bytes.NewReader(data[offset+8 : offset+entrySize])) |
| 171 | if err != nil { |
| 172 | t.Fatalf("decode ICNS entry %q: %v", iconType, err) |
| 173 | } |
| 174 | return img |
| 175 | } |
| 176 | offset += entrySize |
| 177 | } |
| 178 | |
| 179 | t.Fatalf("ICNS is missing %q image", iconType) |
| 180 | return nil |
| 181 | } |
| 182 | |
| 183 | func decodeICOImage(t *testing.T, path string, size int) image.Image { |
| 184 | t.Helper() |
| 185 | |
| 186 | data, err := os.ReadFile(path) |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | r := bytes.NewReader(data) |
| 191 | |
| 192 | var header struct { |
| 193 | Reserved uint16 |
| 194 | Type uint16 |
| 195 | Count uint16 |
| 196 | } |
| 197 | if err := binary.Read(r, binary.LittleEndian, &header); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if header.Reserved != 0 || header.Type != 1 { |
| 201 | t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type) |
| 202 | } |
| 203 | |
| 204 | type iconEntry struct { |
| 205 | Width uint8 |
| 206 | Height uint8 |
| 207 | ColorCount uint8 |
| 208 | Reserved uint8 |
| 209 | Planes uint16 |
| 210 | BitCount uint16 |
| 211 | BytesInRes uint32 |
| 212 | ImageOffset uint32 |
| 213 | } |
| 214 | |
| 215 | entries := make([]iconEntry, header.Count) |
| 216 | for i := range entries { |
| 217 | if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false} |
| 223 | targetIndex := -1 |
| 224 | for i, entry := range entries { |
| 225 | width := int(entry.Width) |
| 226 | height := int(entry.Height) |
| 227 | if width == 0 { |
| 228 | width = 256 |
| 229 | } |
| 230 | if height == 0 { |
| 231 | height = 256 |
| 232 | } |
| 233 | if width != height { |
| 234 | t.Fatalf("ICO image must be square, got %dx%d", width, height) |
| 235 | } |
| 236 | if _, ok := expectedSizes[width]; ok { |
| 237 | expectedSizes[width] = true |
| 238 | } |
| 239 | if width == size { |
| 240 | targetIndex = i |
| 241 | } |
| 242 | } |
| 243 | for expectedSize, found := range expectedSizes { |
| 244 | if !found { |
| 245 | t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize) |
| 246 | } |
| 247 | } |
| 248 | if targetIndex < 0 { |
| 249 | t.Fatalf("ICO is missing %dx%d image", size, size) |
| 250 | } |
| 251 | |
| 252 | entry := entries[targetIndex] |
| 253 | end := int(entry.ImageOffset + entry.BytesInRes) |
| 254 | if end > len(data) { |
| 255 | t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data)) |
| 256 | } |
| 257 | img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end])) |
| 258 | if err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | return img |
| 262 | } |
| 263 |