Skip to content

Commit f35f63f

Browse files
authored
Merge pull request #995 from kivy/tests/xcassets-unit-tests
✅ Add unit tests for xcassets image processing
2 parents 822fc65 + 52cf979 commit f35f63f

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

.github/workflows/kivy_ios.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,29 @@ jobs:
1919
pip install tox>=2.0
2020
tox -e pep8
2121
22+
# Unit tests for the Python side of kivy-ios (e.g. tools/external/xcassets.py).
23+
# Runs on Ubuntu on purpose: it's cheap/fast and the absence of macOS binaries
24+
# like `sips` forces tests to be hermetic (proper mocking). macOS integration
25+
# coverage stays in the build_python3_kivy* jobs below.
26+
unit_tests:
27+
name: Unit tests (Ubuntu)
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout kivy-ios
31+
uses: actions/checkout@v5
32+
- name: Set up Python 3.x
33+
uses: actions/setup-python@v6
34+
with:
35+
python-version: '3.x'
36+
- name: Install kivy-ios and test dependencies
37+
run: |
38+
python -m pip install --upgrade pip
39+
pip install -e .
40+
pip install pytest
41+
- name: Run pytest
42+
run: |
43+
pytest tests/tools -v
44+
2245
build_python3_kivy:
2346
runs-on: ${{ matrix.runs_on }}
2447
strategy:
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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

Comments
 (0)