diff --git a/.github/workflows/kivy_ios.yml b/.github/workflows/kivy_ios.yml index 275faee9..93c66dc3 100644 --- a/.github/workflows/kivy_ios.yml +++ b/.github/workflows/kivy_ios.yml @@ -19,6 +19,29 @@ jobs: pip install tox>=2.0 tox -e pep8 + # Unit tests for the Python side of kivy-ios (e.g. tools/external/xcassets.py). + # Runs on Ubuntu on purpose: it's cheap/fast and the absence of macOS binaries + # like `sips` forces tests to be hermetic (proper mocking). macOS integration + # coverage stays in the build_python3_kivy* jobs below. + unit_tests: + name: Unit tests (Ubuntu) + runs-on: ubuntu-latest + steps: + - name: Checkout kivy-ios + uses: actions/checkout@v5 + - name: Set up Python 3.x + uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Install kivy-ios and test dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + - name: Run pytest + run: | + pytest tests/tools -v + build_python3_kivy: runs-on: ${{ matrix.runs_on }} strategy: diff --git a/tests/tools/external/test_xcassets.py b/tests/tools/external/test_xcassets.py new file mode 100644 index 00000000..99212d11 --- /dev/null +++ b/tests/tools/external/test_xcassets.py @@ -0,0 +1,171 @@ +"""Unit tests for kivy_ios.tools.external.xcassets. + +These cover the Python-side pieces that can be exercised without macOS's +`sips` binary: the Pillow-based `_buildimage` (used by launch images) and +the argument list built by `_generate` for the sips invocation. +""" +from os.path import join +from unittest.mock import patch + +from PIL import Image + +from kivy_ios.tools.external import xcassets + + +def _save_palette_png(path): + """Write a tiny palette-mode ("P") PNG — triggers the original crash.""" + im = Image.new("P", (20, 20), color=5) + palette = [] + for i in range(256): + palette.extend((i, i, i)) + im.putpalette(palette) + im.save(path) + + +def _save_rgb_png(path, size=(20, 20), color=(10, 20, 30)): + Image.new("RGB", size, color).save(path) + + +class TestBuildImage: + def test_palette_mode_does_not_crash(self, tmp_path): + """Palette-mode PNGs must not raise, regression for PR #994. + + Before the `.convert("RGBA")` fix, `im.getpixel((0, 0))` returns an + int for "P" mode and `bgcolor[:3]` raises TypeError. + """ + src = tmp_path / "in.png" + dst = tmp_path / "out.png" + _save_palette_png(str(src)) + + xcassets._buildimage(str(src), str(dst), [100, 100]) + + assert dst.exists() + with Image.open(dst) as out: + assert out.size == (100, 100) + assert out.mode == "RGB" + + def test_rgb_centers_and_pads(self, tmp_path): + """RGB source smaller than target is centered on a bg-color canvas.""" + src = tmp_path / "in.png" + dst = tmp_path / "out.png" + _save_rgb_png(str(src), size=(10, 10), color=(10, 20, 30)) + + xcassets._buildimage(str(src), str(dst), [30, 30]) + + with Image.open(dst) as out: + assert out.size == (30, 30) + assert out.getpixel((0, 0)) == (10, 20, 30) + assert out.getpixel((15, 15)) == (10, 20, 30) + + def test_resizes_oversized_source(self, tmp_path): + """Source larger than target is scaled down preserving aspect ratio.""" + src = tmp_path / "in.png" + dst = tmp_path / "out.png" + _save_rgb_png(str(src), size=(200, 100)) + + xcassets._buildimage(str(src), str(dst), [50, 50]) + + with Image.open(dst) as out: + assert out.size == (50, 50) + + def test_accepts_list_size(self, tmp_path): + """`size` arrives as a list from _generate — Image.new wants a tuple. + + Regression for the `tuple(size)` hunk in PR #994. + """ + src = tmp_path / "in.png" + dst = tmp_path / "out.png" + _save_rgb_png(str(src)) + + xcassets._buildimage(str(src), str(dst), [40, 40]) + assert dst.exists() + + +class TestGenerateIcon: + def test_forces_exact_dimensions(self, tmp_path): + """Icon generation must call sips with `-z H W`, not `-Z max`. + + Apple rejects non-square icons. The old `-Z` only bounded the + largest side and kept aspect ratio, producing rectangular output + for rectangular sources. PR #994 switches to `-z c c` to force an + exact square. + """ + image_xcassets = tmp_path + (image_xcassets / "AppIcon.appiconset").mkdir() + + options = (("120", None, "Icon120.png"),) + src_image = tmp_path / "src.png" + _save_rgb_png(str(src_image)) + + with patch.object(xcassets.sh, "sips", create=True) as mock_sips: + xcassets._generate( + "AppIcon.appiconset", + str(image_xcassets), + str(src_image), + options, + icon=True, + ) + + mock_sips.assert_called_once() + args = mock_sips.call_args.args + + assert "-z" in args, f"expected -z flag, got: {args}" + assert "-Z" not in args, f"legacy -Z flag still present: {args}" + + z_index = args.index("-z") + assert args[z_index + 1] == "120" + assert args[z_index + 2] == "120" + + assert "--out" in args + out_index = args.index("--out") + assert args[out_index + 1] == join( + str(image_xcassets), "AppIcon.appiconset", "Icon120.png" + ) + + def test_uses_in_fn_when_provided(self, tmp_path): + """When `in_fn` is set, sips reads from the already-generated larger + icon in the appiconset dir rather than the user-provided source.""" + image_xcassets = tmp_path + (image_xcassets / "AppIcon.appiconset").mkdir() + + options = (("60", "Icon120.png", "Icon60.png"),) + src_image = tmp_path / "src.png" + _save_rgb_png(str(src_image)) + + with patch.object(xcassets.sh, "sips", create=True) as mock_sips: + xcassets._generate( + "AppIcon.appiconset", + str(image_xcassets), + str(src_image), + options, + icon=True, + ) + + args = mock_sips.call_args.args + assert args[0] == join( + str(image_xcassets), "AppIcon.appiconset", "Icon120.png" + ) + + +class TestGenerateLaunchImage: + def test_calls_buildimage(self, tmp_path): + """Non-icon path skips sips and routes through Pillow-based _buildimage.""" + image_xcassets = tmp_path + (image_xcassets / "LaunchImage.launchimage").mkdir() + + options = (("40 30", None, "Default40x30.png"),) + src_image = tmp_path / "src.png" + _save_rgb_png(str(src_image), size=(10, 10)) + + xcassets._generate( + "LaunchImage.launchimage", + str(image_xcassets), + str(src_image), + options, + icon=False, + ) + + out = image_xcassets / "LaunchImage.launchimage" / "Default40x30.png" + assert out.exists() + with Image.open(out) as im: + assert im.size == (40, 30)