|
| 1 | +"""Unit tests for kivy_ios.tools.external.xcassets. |
| 2 | +
|
| 3 | +These cover the Python-side pieces that can be exercised without macOS's |
| 4 | +`sips` binary: the Pillow-based `_buildimage` (used by launch images) and |
| 5 | +the argument list built by `_generate` for the sips invocation. |
| 6 | +""" |
| 7 | +from os.path import join |
| 8 | +from unittest.mock import patch |
| 9 | + |
| 10 | +from PIL import Image |
| 11 | + |
| 12 | +from kivy_ios.tools.external import xcassets |
| 13 | + |
| 14 | + |
| 15 | +def _save_palette_png(path): |
| 16 | + """Write a tiny palette-mode ("P") PNG — triggers the original crash.""" |
| 17 | + im = Image.new("P", (20, 20), color=5) |
| 18 | + palette = [] |
| 19 | + for i in range(256): |
| 20 | + palette.extend((i, i, i)) |
| 21 | + im.putpalette(palette) |
| 22 | + im.save(path) |
| 23 | + |
| 24 | + |
| 25 | +def _save_rgb_png(path, size=(20, 20), color=(10, 20, 30)): |
| 26 | + Image.new("RGB", size, color).save(path) |
| 27 | + |
| 28 | + |
| 29 | +class TestBuildImage: |
| 30 | + def test_palette_mode_does_not_crash(self, tmp_path): |
| 31 | + """Palette-mode PNGs must not raise, regression for PR #994. |
| 32 | +
|
| 33 | + Before the `.convert("RGBA")` fix, `im.getpixel((0, 0))` returns an |
| 34 | + int for "P" mode and `bgcolor[:3]` raises TypeError. |
| 35 | + """ |
| 36 | + src = tmp_path / "in.png" |
| 37 | + dst = tmp_path / "out.png" |
| 38 | + _save_palette_png(str(src)) |
| 39 | + |
| 40 | + xcassets._buildimage(str(src), str(dst), [100, 100]) |
| 41 | + |
| 42 | + assert dst.exists() |
| 43 | + with Image.open(dst) as out: |
| 44 | + assert out.size == (100, 100) |
| 45 | + assert out.mode == "RGB" |
| 46 | + |
| 47 | + def test_rgb_centers_and_pads(self, tmp_path): |
| 48 | + """RGB source smaller than target is centered on a bg-color canvas.""" |
| 49 | + src = tmp_path / "in.png" |
| 50 | + dst = tmp_path / "out.png" |
| 51 | + _save_rgb_png(str(src), size=(10, 10), color=(10, 20, 30)) |
| 52 | + |
| 53 | + xcassets._buildimage(str(src), str(dst), [30, 30]) |
| 54 | + |
| 55 | + with Image.open(dst) as out: |
| 56 | + assert out.size == (30, 30) |
| 57 | + assert out.getpixel((0, 0)) == (10, 20, 30) |
| 58 | + assert out.getpixel((15, 15)) == (10, 20, 30) |
| 59 | + |
| 60 | + def test_resizes_oversized_source(self, tmp_path): |
| 61 | + """Source larger than target is scaled down preserving aspect ratio.""" |
| 62 | + src = tmp_path / "in.png" |
| 63 | + dst = tmp_path / "out.png" |
| 64 | + _save_rgb_png(str(src), size=(200, 100)) |
| 65 | + |
| 66 | + xcassets._buildimage(str(src), str(dst), [50, 50]) |
| 67 | + |
| 68 | + with Image.open(dst) as out: |
| 69 | + assert out.size == (50, 50) |
| 70 | + |
| 71 | + def test_accepts_list_size(self, tmp_path): |
| 72 | + """`size` arrives as a list from _generate — Image.new wants a tuple. |
| 73 | +
|
| 74 | + Regression for the `tuple(size)` hunk in PR #994. |
| 75 | + """ |
| 76 | + src = tmp_path / "in.png" |
| 77 | + dst = tmp_path / "out.png" |
| 78 | + _save_rgb_png(str(src)) |
| 79 | + |
| 80 | + xcassets._buildimage(str(src), str(dst), [40, 40]) |
| 81 | + assert dst.exists() |
| 82 | + |
| 83 | + |
| 84 | +class TestGenerateIcon: |
| 85 | + def test_forces_exact_dimensions(self, tmp_path): |
| 86 | + """Icon generation must call sips with `-z H W`, not `-Z max`. |
| 87 | +
|
| 88 | + Apple rejects non-square icons. The old `-Z` only bounded the |
| 89 | + largest side and kept aspect ratio, producing rectangular output |
| 90 | + for rectangular sources. PR #994 switches to `-z c c` to force an |
| 91 | + exact square. |
| 92 | + """ |
| 93 | + image_xcassets = tmp_path |
| 94 | + (image_xcassets / "AppIcon.appiconset").mkdir() |
| 95 | + |
| 96 | + options = (("120", None, "Icon120.png"),) |
| 97 | + src_image = tmp_path / "src.png" |
| 98 | + _save_rgb_png(str(src_image)) |
| 99 | + |
| 100 | + with patch.object(xcassets.sh, "sips", create=True) as mock_sips: |
| 101 | + xcassets._generate( |
| 102 | + "AppIcon.appiconset", |
| 103 | + str(image_xcassets), |
| 104 | + str(src_image), |
| 105 | + options, |
| 106 | + icon=True, |
| 107 | + ) |
| 108 | + |
| 109 | + mock_sips.assert_called_once() |
| 110 | + args = mock_sips.call_args.args |
| 111 | + |
| 112 | + assert "-z" in args, f"expected -z flag, got: {args}" |
| 113 | + assert "-Z" not in args, f"legacy -Z flag still present: {args}" |
| 114 | + |
| 115 | + z_index = args.index("-z") |
| 116 | + assert args[z_index + 1] == "120" |
| 117 | + assert args[z_index + 2] == "120" |
| 118 | + |
| 119 | + assert "--out" in args |
| 120 | + out_index = args.index("--out") |
| 121 | + assert args[out_index + 1] == join( |
| 122 | + str(image_xcassets), "AppIcon.appiconset", "Icon120.png" |
| 123 | + ) |
| 124 | + |
| 125 | + def test_uses_in_fn_when_provided(self, tmp_path): |
| 126 | + """When `in_fn` is set, sips reads from the already-generated larger |
| 127 | + icon in the appiconset dir rather than the user-provided source.""" |
| 128 | + image_xcassets = tmp_path |
| 129 | + (image_xcassets / "AppIcon.appiconset").mkdir() |
| 130 | + |
| 131 | + options = (("60", "Icon120.png", "Icon60.png"),) |
| 132 | + src_image = tmp_path / "src.png" |
| 133 | + _save_rgb_png(str(src_image)) |
| 134 | + |
| 135 | + with patch.object(xcassets.sh, "sips", create=True) as mock_sips: |
| 136 | + xcassets._generate( |
| 137 | + "AppIcon.appiconset", |
| 138 | + str(image_xcassets), |
| 139 | + str(src_image), |
| 140 | + options, |
| 141 | + icon=True, |
| 142 | + ) |
| 143 | + |
| 144 | + args = mock_sips.call_args.args |
| 145 | + assert args[0] == join( |
| 146 | + str(image_xcassets), "AppIcon.appiconset", "Icon120.png" |
| 147 | + ) |
| 148 | + |
| 149 | + |
| 150 | +class TestGenerateLaunchImage: |
| 151 | + def test_calls_buildimage(self, tmp_path): |
| 152 | + """Non-icon path skips sips and routes through Pillow-based _buildimage.""" |
| 153 | + image_xcassets = tmp_path |
| 154 | + (image_xcassets / "LaunchImage.launchimage").mkdir() |
| 155 | + |
| 156 | + options = (("40 30", None, "Default40x30.png"),) |
| 157 | + src_image = tmp_path / "src.png" |
| 158 | + _save_rgb_png(str(src_image), size=(10, 10)) |
| 159 | + |
| 160 | + xcassets._generate( |
| 161 | + "LaunchImage.launchimage", |
| 162 | + str(image_xcassets), |
| 163 | + str(src_image), |
| 164 | + options, |
| 165 | + icon=False, |
| 166 | + ) |
| 167 | + |
| 168 | + out = image_xcassets / "LaunchImage.launchimage" / "Default40x30.png" |
| 169 | + assert out.exists() |
| 170 | + with Image.open(out) as im: |
| 171 | + assert im.size == (40, 30) |
0 commit comments