appicon_test.go (13394B)
1 package appicon 2 3 import ( 4 "bytes" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "image" 9 "image/color" 10 "image/png" 11 "os" 12 "path/filepath" 13 "reflect" 14 "sort" 15 "testing" 16 ) 17 18 func art(side int) image.Image { 19 img := image.NewNRGBA(image.Rect(0, 0, side, side)) 20 for y := 0; y < side; y++ { 21 for x := 0; x < side; x++ { 22 img.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 0x80, A: 0xFF}) 23 } 24 } 25 return img 26 } 27 28 func paths(files map[string][]byte) []string { 29 out := make([]string, 0, len(files)) 30 for name := range files { 31 out = append(out, name) 32 } 33 sort.Strings(out) 34 return out 35 } 36 37 // decode reads the manifest back as loosely typed JSON, so the test asserts 38 // the shape actool reads rather than the Go types that produced it. 39 func decode(t *testing.T, files map[string][]byte) map[string]any { 40 t.Helper() 41 var doc map[string]any 42 if err := json.Unmarshal(files["icon.json"], &doc); err != nil { 43 t.Fatalf("manifest does not parse: %v", err) 44 } 45 return doc 46 } 47 48 func TestFilesHoldsAManifestAndItsLayers(t *testing.T) { 49 files, err := New(art(64), "Probe").Files() 50 if err != nil { 51 t.Fatalf("rendering: %v", err) 52 } 53 want := []string{"Assets/Probe.png", "icon.json"} 54 if got := paths(files); !reflect.DeepEqual(got, want) { 55 t.Errorf("files are %v, want %v", got, want) 56 } 57 img, err := png.Decode(bytes.NewReader(files["Assets/Probe.png"])) 58 if err != nil { 59 t.Fatalf("the layer is not a png: %v", err) 60 } 61 if got := img.Bounds().Size(); got.X != 64 || got.Y != 64 { 62 t.Errorf("layer is %v, want the artwork it was given", got) 63 } 64 } 65 66 // TestManifestMatchesTheShapeIconComposerWrites pins the key names against a 67 // manifest taken from a shipping app's bundle. 68 func TestManifestMatchesTheShapeIconComposerWrites(t *testing.T) { 69 files, err := New(art(32), "Mist").Files() 70 if err != nil { 71 t.Fatalf("rendering: %v", err) 72 } 73 doc := decode(t, files) 74 if doc["fill"] != FillAutomatic { 75 t.Errorf("fill is %v, want %q", doc["fill"], FillAutomatic) 76 } 77 platforms, ok := doc["supported-platforms"].(map[string]any) 78 if !ok { 79 t.Fatalf("supported-platforms is %T, want an object", doc["supported-platforms"]) 80 } 81 if squares, _ := platforms["squares"].([]any); len(squares) != 1 || squares[0] != PlatformMac { 82 t.Errorf("squares are %v, want [%s]", platforms["squares"], PlatformMac) 83 } 84 groups, ok := doc["groups"].([]any) 85 if !ok || len(groups) != 1 { 86 t.Fatalf("groups are %v, want one", doc["groups"]) 87 } 88 group := groups[0].(map[string]any) 89 for key, want := range map[string]any{ 90 "shadow": map[string]any{"kind": ShadowNeutral, "opacity": 0.5}, 91 "translucency": map[string]any{"enabled": true, "value": 0.5}, 92 } { 93 if got := group[key]; !reflect.DeepEqual(got, want) { 94 t.Errorf("%s is %v, want %v", key, got, want) 95 } 96 } 97 layers := group["layers"].([]any) 98 if len(layers) != 1 { 99 t.Fatalf("layers are %v, want one", layers) 100 } 101 want := map[string]any{"glass": false, "image-name": "Mist.png", "name": "Mist"} 102 if got := layers[0]; !reflect.DeepEqual(got, want) { 103 t.Errorf("layer is %v, want %v", got, want) 104 } 105 } 106 107 func TestGlassAndSeveralGroups(t *testing.T) { 108 b := Bundle{ 109 Groups: []Group{ 110 {Layers: []Layer{{Name: "Back", Image: art(16)}}}, 111 { 112 Layers: []Layer{{Name: "Front", Image: art(16), Glass: true}}, 113 Shadow: &Shadow{Kind: "layer-color", Opacity: 0.25}, 114 Translucency: &Translucency{Enabled: false, Value: 0.8}, 115 }, 116 }, 117 } 118 files, err := b.Files() 119 if err != nil { 120 t.Fatalf("rendering: %v", err) 121 } 122 want := []string{"Assets/Back.png", "Assets/Front.png", "icon.json"} 123 if got := paths(files); !reflect.DeepEqual(got, want) { 124 t.Fatalf("files are %v, want %v", got, want) 125 } 126 groups := decode(t, files)["groups"].([]any) 127 if len(groups) != 2 { 128 t.Fatalf("groups are %v, want two", groups) 129 } 130 // A group given neither is written without them rather than with zeroes. 131 first := groups[0].(map[string]any) 132 if _, ok := first["shadow"]; ok { 133 t.Errorf("a group with no shadow wrote one: %v", first) 134 } 135 if _, ok := first["translucency"]; ok { 136 t.Errorf("a group with no translucency wrote one: %v", first) 137 } 138 second := groups[1].(map[string]any) 139 if got := second["shadow"]; !reflect.DeepEqual(got, map[string]any{"kind": "layer-color", "opacity": 0.25}) { 140 t.Errorf("shadow is %v", got) 141 } 142 if got := second["layers"].([]any)[0].(map[string]any)["glass"]; got != true { 143 t.Errorf("glass is %v, want true", got) 144 } 145 } 146 147 // TestSpecializedValuesReplaceThePlainOnes covers the pairs where the 148 // manifest holds either a value or a list of them specialised by appearance, 149 // never both. 150 func TestSpecializedValuesReplaceThePlainOnes(t *testing.T) { 151 material := 1.0 152 b := Bundle{ 153 Fill: "automatic", 154 Fills: []Specialized[Fill]{{Value: NamedFill("system-light")}}, 155 Groups: []Group{{ 156 Layers: []Layer{{Name: "One", Image: art(16)}}, 157 Translucency: &Translucency{Enabled: true, Value: 0.5}, 158 Translucencies: []Specialized[Translucency]{{Value: Translucency{Enabled: false, Value: 0.8}}}, 159 BlurMaterial: &material, 160 BlurMaterials: []Specialized[float64]{{Value: 2}}, 161 }}, 162 } 163 files, err := b.Files() 164 if err != nil { 165 t.Fatalf("rendering: %v", err) 166 } 167 doc := decode(t, files) 168 if _, ok := doc["fill"]; ok { 169 t.Errorf("wrote a plain fill beside the specialised one: %v", doc["fill"]) 170 } 171 if _, ok := doc["fill-specializations"]; !ok { 172 t.Error("did not write the specialised fill") 173 } 174 group := doc["groups"].([]any)[0].(map[string]any) 175 for _, plain := range []string{"translucency", "blur-material"} { 176 if _, ok := group[plain]; ok { 177 t.Errorf("wrote a plain %s beside the specialised one: %v", plain, group[plain]) 178 } 179 } 180 for _, special := range []string{"translucency-specializations", "blur-material-specializations"} { 181 if _, ok := group[special]; !ok { 182 t.Errorf("did not write %s", special) 183 } 184 } 185 } 186 187 // TestRichManifestMatchesTheShippingShape builds the manifest a composed icon 188 // needs and holds its keys against the ones a shipping app's bundle uses. 189 func TestRichManifestMatchesTheShippingShape(t *testing.T) { 190 b := Bundle{ 191 Fills: []Specialized[Fill]{ 192 {Value: NamedFill("system-light")}, 193 {Appearance: AppearanceDark, Value: NamedFill("system-dark")}, 194 }, 195 Groups: []Group{{ 196 BlendModes: []Specialized[string]{{Appearance: AppearanceTinted, Value: "normal"}}, 197 Lighting: "individual", 198 Specular: true, 199 Shadow: &Shadow{Kind: "layer-color", Opacity: 0.5}, 200 Translucencies: []Specialized[Translucency]{ 201 {Value: Translucency{Enabled: true, Value: 0.84}}, 202 {Appearance: AppearanceTinted, Value: Translucency{Enabled: false, Value: 0.84}}, 203 }, 204 Layers: []Layer{{ 205 Name: "Cube", 206 Image: art(32), 207 Glass: true, 208 Position: &Position{Scale: 1.24, Translation: [2]float64{0, 0}}, 209 Fills: []Specialized[Fill]{ 210 {Appearance: AppearanceDark, Value: NamedFill("automatic")}, 211 {Appearance: AppearanceTinted, Value: GradientFill( 212 "display-p3:0.90000,0.90000,0.90000,0.83000", 213 "srgb:1.00000,1.00000,1.00000,0.41987", 214 )}, 215 }, 216 BlendModes: []Specialized[string]{{Appearance: AppearanceDark, Value: "lighten"}}, 217 }}, 218 }}, 219 } 220 files, err := b.Files() 221 if err != nil { 222 t.Fatalf("rendering: %v", err) 223 } 224 doc := decode(t, files) 225 group := doc["groups"].([]any)[0].(map[string]any) 226 for _, key := range []string{ 227 "blend-mode-specializations", "lighting", "shadow", "specular", 228 "translucency-specializations", "layers", 229 } { 230 if _, ok := group[key]; !ok { 231 t.Errorf("group is missing %s", key) 232 } 233 } 234 layer := group["layers"].([]any)[0].(map[string]any) 235 for _, key := range []string{ 236 "blend-mode-specializations", "fill-specializations", "glass", 237 "image-name", "name", "position", 238 } { 239 if _, ok := layer[key]; !ok { 240 t.Errorf("layer is missing %s", key) 241 } 242 } 243 // A gradient is the one fill written as an object naming its kind. 244 tinted := layer["fill-specializations"].([]any)[1].(map[string]any) 245 gradient, ok := tinted["value"].(map[string]any)["linear-gradient"].([]any) 246 if !ok || len(gradient) != 2 { 247 t.Errorf("gradient is %v, want two stops", tinted["value"]) 248 } 249 if got := layer["position"].(map[string]any)["scale"]; got != 1.24 { 250 t.Errorf("scale is %v, want 1.24", got) 251 } 252 } 253 254 // TestHiddenIsWrittenOnlyWhenSet keeps a manifest from carrying a key for 255 // every default a layer did not set. 256 func TestHiddenIsWrittenOnlyWhenSet(t *testing.T) { 257 files, err := New(art(16), "Plain").Files() 258 if err != nil { 259 t.Fatalf("rendering: %v", err) 260 } 261 layer := decode(t, files)["groups"].([]any)[0].(map[string]any)["layers"].([]any)[0].(map[string]any) 262 if _, ok := layer["hidden"]; ok { 263 t.Errorf("wrote hidden for a layer that is not: %v", layer) 264 } 265 b := Bundle{Groups: []Group{{Layers: []Layer{{Name: "Gone", Image: art(16), Hidden: true}}}}} 266 files, err = b.Files() 267 if err != nil { 268 t.Fatalf("rendering: %v", err) 269 } 270 layer = decode(t, files)["groups"].([]any)[0].(map[string]any)["layers"].([]any)[0].(map[string]any) 271 if layer["hidden"] != true { 272 t.Errorf("hidden is %v, want true", layer["hidden"]) 273 } 274 } 275 276 // TestEveryKeyIsTheManifestSpelling walks the whole manifest for a key 277 // spelled the way Go spells a field rather than the way the manifest does. A 278 // value marshalled through a type that carries no tags looks right until 279 // actool reads it, and actool does not say which key it wanted. 280 func TestEveryKeyIsTheManifestSpelling(t *testing.T) { 281 material := 1.0 282 b := Bundle{ 283 Fills: []Specialized[Fill]{{Value: NamedFill("system-light")}}, 284 Groups: []Group{{ 285 BlendModes: []Specialized[string]{{Appearance: AppearanceTinted, Value: "normal"}}, 286 BlurMaterials: []Specialized[float64]{{Value: material}}, 287 Lighting: "individual", 288 Specular: true, 289 Shadow: &Shadow{Kind: "layer-color", Opacity: 0.5}, 290 Translucencies: []Specialized[Translucency]{ 291 {Value: Translucency{Enabled: true, Value: 0.84}}, 292 {Appearance: AppearanceDark, Value: Translucency{Enabled: false, Value: 0.5}}, 293 }, 294 Layers: []Layer{{ 295 Name: "One", Image: art(16), Glass: true, Hidden: true, 296 Position: &Position{Scale: 1.24, Translation: [2]float64{0, -12}}, 297 Fills: []Specialized[Fill]{{Value: GradientFill("srgb:1,1,1,1")}}, 298 BlendModes: []Specialized[string]{{Appearance: AppearanceDark, Value: "lighten"}}, 299 }}, 300 }}, 301 } 302 // The plain forms travel a different path from the specialised ones, so 303 // both are walked. 304 plain := Bundle{Groups: []Group{{ 305 Layers: []Layer{{Name: "One", Image: art(16)}}, 306 Shadow: &Shadow{Kind: ShadowNeutral, Opacity: 0.5}, 307 Translucency: &Translucency{Enabled: true, Value: 0.5}, 308 }}} 309 for _, bundle := range []Bundle{b, plain} { 310 files, err := bundle.Files() 311 if err != nil { 312 t.Fatalf("rendering: %v", err) 313 } 314 var walk func(any, string) 315 walk = func(node any, path string) { 316 switch v := node.(type) { 317 case map[string]any: 318 for key, child := range v { 319 if key != "" && key[0] >= 'A' && key[0] <= 'Z' { 320 t.Errorf("%s.%s is spelled the way Go spells a field", path, key) 321 } 322 walk(child, path+"."+key) 323 } 324 case []any: 325 for i, child := range v { 326 walk(child, fmt.Sprintf("%s[%d]", path, i)) 327 } 328 } 329 } 330 walk(any(decode(t, files)), "") 331 } 332 } 333 334 func TestFilesRejectsWhatItCannotWrite(t *testing.T) { 335 for _, tt := range []struct { 336 name string 337 bundle Bundle 338 want error 339 }{ 340 {"no groups", Bundle{}, ErrNoLayers}, 341 {"no layers", Bundle{Groups: []Group{{}}}, ErrNoLayers}, 342 { 343 name: "two layers of one name", 344 bundle: Bundle{Groups: []Group{{Layers: []Layer{ 345 {Name: "Same", Image: art(16)}, 346 {Name: "Same", Image: art(16)}, 347 }}}}, 348 want: ErrDuplicateLayer, 349 }, 350 } { 351 t.Run(tt.name, func(t *testing.T) { 352 if _, err := tt.bundle.Files(); !errors.Is(err, tt.want) { 353 t.Errorf("error is %v, want %v", err, tt.want) 354 } 355 }) 356 } 357 } 358 359 func TestFilesRejectsALayerWithoutArtwork(t *testing.T) { 360 b := Bundle{Groups: []Group{{Layers: []Layer{{Name: "Empty"}}}}} 361 if _, err := b.Files(); err == nil { 362 t.Error("rendering a layer with no image returned no error") 363 } 364 } 365 366 // TestLayerNamesBecomeFileNames keeps a name that cannot be a file from 367 // producing a manifest pointing at something unwritable. 368 func TestLayerNamesBecomeFileNames(t *testing.T) { 369 b := Bundle{Groups: []Group{{Layers: []Layer{ 370 {Name: "../escape", Image: art(16)}, 371 {Name: "", Image: art(16)}, 372 }}}} 373 files, err := b.Files() 374 if err != nil { 375 t.Fatalf("rendering: %v", err) 376 } 377 want := []string{"Assets/Layer 2.png", "Assets/escape.png", "icon.json"} 378 if got := paths(files); !reflect.DeepEqual(got, want) { 379 t.Errorf("files are %v, want %v", got, want) 380 } 381 } 382 383 func TestWriteBuildsTheDirectory(t *testing.T) { 384 dir := filepath.Join(t.TempDir(), "Probe.icon") 385 if err := New(art(64), "Probe").Write(dir); err != nil { 386 t.Fatalf("writing: %v", err) 387 } 388 for _, name := range []string{"icon.json", filepath.Join("Assets", "Probe.png")} { 389 if _, err := os.Stat(filepath.Join(dir, name)); err != nil { 390 t.Errorf("bundle is missing %s: %v", name, err) 391 } 392 } 393 // The manifest on disk is the one Files rendered. 394 onDisk, err := os.ReadFile(filepath.Join(dir, "icon.json")) 395 if err != nil { 396 t.Fatalf("reading the manifest: %v", err) 397 } 398 files, err := New(art(64), "Probe").Files() 399 if err != nil { 400 t.Fatalf("rendering: %v", err) 401 } 402 if !bytes.Equal(onDisk, files["icon.json"]) { 403 t.Error("the manifest on disk differs from the one rendered") 404 } 405 }