icns

Easily create .icns files (Mac Icons) with this Go library or the included CLI.
Log | Files | Refs | LICENSE

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 }