diff --git a/src/launchpad/artifacts/apple/zipped_xcarchive.py b/src/launchpad/artifacts/apple/zipped_xcarchive.py index ccf8b3fd..76b7e411 100644 --- a/src/launchpad/artifacts/apple/zipped_xcarchive.py +++ b/src/launchpad/artifacts/apple/zipped_xcarchive.py @@ -68,6 +68,32 @@ def get_plist(self) -> dict[str, Any]: except Exception as e: raise RuntimeError("Failed to parse Info.plist") from e + def get_icon_info(self) -> tuple[str | None, list[str]]: + """Extract primary and alternate icon names from Info.plist. + + Returns: + Tuple of (primary_icon_name, alternate_icon_names list) + """ + plist = self.get_plist() + bundle_icons = plist.get("CFBundleIcons", {}) + + primary_icon_name: str | None = None + alternate_icon_names: list[str] = [] + + primary_icon = bundle_icons.get("CFBundlePrimaryIcon", {}) + if isinstance(primary_icon, dict): + primary_icon_name = primary_icon.get("CFBundleIconName") + + alternate_icons = bundle_icons.get("CFBundleAlternateIcons", {}) + if isinstance(alternate_icons, dict): + for icon_key, icon_data in alternate_icons.items(): + if isinstance(icon_data, dict): + icon_name = icon_data.get("CFBundleIconName") + if icon_name: + alternate_icon_names.append(icon_name) + + return primary_icon_name, alternate_icon_names + def generate_ipa(self, output_path: Path): """Generate an IPA file diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index b939faa0..1dc4e403 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -23,6 +23,7 @@ from launchpad.parsers.apple.swift_symbol_type_aggregator import SwiftSymbolTypeAggregator from launchpad.size.constants import APPLE_FILESYSTEM_BLOCK_SIZE from launchpad.size.hermes.utils import make_hermes_reports +from launchpad.size.insights.apple.alternate_icons_optimization import AlternateIconsOptimizationInsight from launchpad.size.insights.apple.image_optimization import ImageOptimizationInsight from launchpad.size.insights.apple.localized_strings import LocalizedStringsInsight from launchpad.size.insights.apple.localized_strings_minify import MinifyLocalizedStringsInsight @@ -227,6 +228,9 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults: unnecessary_files=self._generate_insight_with_tracing( UnnecessaryFilesInsight, insights_input, "unnecessary_files" ), + alternate_icons_optimization=self._generate_insight_with_tracing( + AlternateIconsOptimizationInsight, insights_input, "alternate_icons_optimization" + ), # TODO: enable audio/video compression insights once we handle ffmpeg # audio_compression=self._generate_insight_with_tracing( # AudioCompressionInsight, insights_input, "audio_compression" @@ -293,6 +297,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: is_code_signature_valid = False code_signature_errors = [str(e)] + primary_icon_name, alternate_icon_names = xcarchive.get_icon_info() + return AppleAppInfo( name=plist.get("CFBundleName", "Unknown"), app_id=plist.get("CFBundleIdentifier", "unknown.bundle.id"), @@ -310,6 +316,8 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: is_code_signature_valid=is_code_signature_valid, code_signature_errors=code_signature_errors, main_binary_uuid=xcarchive.get_main_binary_uuid(), + primary_icon_name=primary_icon_name, + alternate_icon_names=alternate_icon_names, ) def _get_profile_type(self, profile_data: dict[str, Any]) -> Tuple[str, str]: diff --git a/src/launchpad/size/insights/apple/alternate_icons_optimization.py b/src/launchpad/size/insights/apple/alternate_icons_optimization.py new file mode 100644 index 00000000..2a182a6c --- /dev/null +++ b/src/launchpad/size/insights/apple/alternate_icons_optimization.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import io + +from pathlib import Path +from typing import List + +from PIL import Image + +from launchpad.size.insights.apple.image_optimization import BaseImageOptimizationInsight +from launchpad.size.insights.insight import InsightsInput +from launchpad.size.models.apple import AppleAppInfo +from launchpad.size.models.common import FileInfo +from launchpad.utils.logging import get_logger + +logger = get_logger(__name__) + + +class AlternateIconsOptimizationInsight(BaseImageOptimizationInsight): + """Analyze alternate app icon optimization opportunities in iOS apps. + + Alternate app icons can be optimized without affecting the App Store listing since + only the primary icon is displayed there. This insight identifies alternate icons + that could be minified or converted to more efficient formats like HEIC. + + Icons are resized to device display size (180px for iPhone 3x) and back to store + size (1024px) before optimization, since they only need quality for homescreen display. + """ + + IPHONE_3X_ICON_SIZE = 180 # Largest icon size displayed on device + APP_STORE_ICON_SIZE = 1024 # Standard App Store icon size + + def _find_images(self, input: InsightsInput) -> List[FileInfo]: + if not isinstance(input.app_info, AppleAppInfo): + return [] + + if not input.app_info.alternate_icon_names: + return [] + + alternate_icon_names = set(input.app_info.alternate_icon_names) + car_files = [f for f in input.file_analysis.files if f.file_type == "car"] + + images: List[FileInfo] = [] + for car_file in car_files: + if not car_file.children or (len(car_file.children) == 1 and car_file.children[0].path.endswith("/Other")): + logger.warning( + "Asset catalog %s has no parsed children. ParsedAssets directory may be missing.", car_file.path + ) + continue + + for child in car_file.children: + if self._is_alternate_icon_file(child, alternate_icon_names): + images.append(child) + + return list({img.path: img for img in images}.values()) + + def _preprocess_image(self, img: Image.Image, file_info: FileInfo) -> tuple[Image.Image, int, int]: + resized = self._resize_icon_for_analysis(img) + + fmt = img.format or "PNG" + with io.BytesIO() as buf: + resized.save(buf, format=fmt) + resized_size = buf.tell() + + baseline_savings = max(0, file_info.size - resized_size) + return resized, resized_size, baseline_savings + + def _resize_icon_for_analysis(self, img: Image.Image) -> Image.Image: + return img.resize((self.IPHONE_3X_ICON_SIZE, self.IPHONE_3X_ICON_SIZE), Image.Resampling.LANCZOS).resize( + (self.APP_STORE_ICON_SIZE, self.APP_STORE_ICON_SIZE), Image.Resampling.LANCZOS + ) + + def _is_alternate_icon_file(self, file_info: FileInfo, alternate_icon_names: set[str]) -> bool: + return file_info.file_type.lower() in self.OPTIMIZABLE_FORMATS and any( + Path(file_info.path).stem.startswith(name) for name in alternate_icon_names + ) diff --git a/src/launchpad/size/insights/apple/image_optimization.py b/src/launchpad/size/insights/apple/image_optimization.py index fcfb65d2..ac511ce8 100644 --- a/src/launchpad/size/insights/apple/image_optimization.py +++ b/src/launchpad/size/insights/apple/image_optimization.py @@ -3,10 +3,11 @@ import io import logging +from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path -from typing import Iterable, List, Sequence +from typing import List import pillow_heif # type: ignore @@ -36,8 +37,8 @@ class _OptimizationResult: optimized_size: int -class ImageOptimizationInsight(Insight[ImageOptimizationInsightResult]): - """Analyse image optimisation opportunities in iOS apps.""" +class BaseImageOptimizationInsight(Insight[ImageOptimizationInsightResult], ABC): + """Base class for image optimization insights with shared analysis logic.""" OPTIMIZABLE_FORMATS = {"png", "jpg", "jpeg", "heif", "heic"} MIN_SAVINGS_THRESHOLD = 4096 @@ -45,8 +46,28 @@ class ImageOptimizationInsight(Insight[ImageOptimizationInsightResult]): TARGET_HEIC_QUALITY = 85 _MAX_WORKERS = 4 + @abstractmethod + def _find_images(self, input: InsightsInput) -> List[FileInfo]: + """Find and return list of images to analyze. Should include deduplication if needed.""" + pass + + def _preprocess_image(self, img: Image.Image, file_info: FileInfo) -> tuple[Image.Image, int, int]: + """Preprocess image before optimization analysis. + + Args: + img: The loaded PIL Image + file_info: File metadata + + Returns: + Tuple of (processed_image, baseline_size, baseline_savings): + - processed_image: The image to analyze for optimization + - baseline_size: Size of the processed image before optimization + - baseline_savings: Savings from preprocessing alone (original - baseline) + """ + return img, file_info.size, 0 + def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | None: # noqa: D401 - files = list(self._iter_optimizable_files(input.file_analysis.files)) + files = self._find_images(input) if not files: return None @@ -58,9 +79,8 @@ def generate(self, input: InsightsInput) -> ImageOptimizationInsightResult | Non result = future.result() if result and result.potential_savings >= self.MIN_SAVINGS_THRESHOLD: results.append(result) - except Exception as exc: # pragma: no cover - file_info = future_to_file[future] - logger.error("Failed to analyse %s: %s", file_info.path, exc) + except Exception: # pragma: no cover + logger.exception("Failed to analyze image in thread pool") if not results: return None @@ -77,49 +97,50 @@ def _analyze_image_optimization( self, file_info: FileInfo, ) -> OptimizableImageFile | None: - minify_savings = 0 - conversion_savings = 0 - minified_size: int | None = None - heic_size: int | None = None - - full_path = file_info.full_path - file_size = file_info.size - file_type = file_info.file_type - display_path = file_info.path - - if full_path is None: - logger.info("Skipping %s because it has no full path", display_path) + if file_info.full_path is None: + logger.info("Skipping %s because it has no full path", file_info.path) return None try: - with Image.open(full_path) as img: + with Image.open(file_info.full_path) as img: img.load() # type: ignore - fmt = (img.format or file_type).lower() + + processed_img, baseline_size, baseline_savings = self._preprocess_image(img, file_info) + + minify_savings = 0 + conversion_savings = 0 + minified_size: int | None = None + heic_size: int | None = None + + fmt = (processed_img.format or file_info.file_type).lower() if fmt in {"png", "jpg", "jpeg"}: - if res := self._check_minification(img, file_size, fmt): + if res := self._check_minification(processed_img, baseline_size, fmt): minify_savings, minified_size = res.savings, res.optimized_size - if res := self._check_heic_conversion(img, file_size): + if res := self._check_heic_conversion(processed_img, baseline_size): conversion_savings, heic_size = res.savings, res.optimized_size elif fmt in {"heif", "heic"}: - if res := self._check_heic_minification(img, file_size): + if res := self._check_heic_minification(processed_img, baseline_size): minify_savings, minified_size = res.savings, res.optimized_size - except Exception as exc: - logger.error("Failed to process %s: %s", display_path, exc) - return None - if max(minify_savings, conversion_savings) < self.MIN_SAVINGS_THRESHOLD: + total_minify = baseline_savings + minify_savings + total_conversion = baseline_savings + conversion_savings + + if max(total_minify, total_conversion) < self.MIN_SAVINGS_THRESHOLD: + return None + + return OptimizableImageFile( + file_path=file_info.path, + current_size=file_info.size, + minify_savings=total_minify, + minified_size=minified_size, + conversion_savings=total_conversion, + heic_size=heic_size, + ) + except Exception: + logger.exception("Failed to open or process image file") return None - return OptimizableImageFile( - file_path=display_path, - current_size=file_size, - minify_savings=minify_savings, - minified_size=minified_size, - conversion_savings=conversion_savings, - heic_size=heic_size, - ) - def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _OptimizationResult | None: try: with io.BytesIO() as buf: @@ -134,8 +155,8 @@ def _check_minification(self, img: Image.Image, file_size: int, fmt: str) -> _Op img.save(buf, format="JPEG", quality=self.TARGET_JPEG_QUALITY, **save_params) new_size = buf.tell() return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None - except Exception as exc: - logger.error("Minification check failed: %s", exc) + except Exception: + logger.exception("Image minification optimization failed") return None def _check_heic_conversion(self, img: Image.Image, file_size: int) -> _OptimizationResult | None: @@ -144,8 +165,8 @@ def _check_heic_conversion(self, img: Image.Image, file_size: int) -> _Optimizat img.save(buf, format="HEIF", quality=self.TARGET_HEIC_QUALITY) new_size = buf.tell() return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None - except Exception as exc: - logger.error("HEIC conversion check failed: %s", exc) + except Exception: + logger.exception("Image HEIC conversion optimization failed") return None def _check_heic_minification(self, img: Image.Image, file_size: int) -> _OptimizationResult | None: @@ -154,16 +175,22 @@ def _check_heic_minification(self, img: Image.Image, file_size: int) -> _Optimiz img.save(buf, format="HEIF", quality=self.TARGET_HEIC_QUALITY) new_size = buf.tell() return _OptimizationResult(file_size - new_size, new_size) if new_size < file_size else None - except Exception as exc: - logger.error("HEIC minification check failed: %s", exc) + except Exception: + logger.exception("HEIC image minification failed") return None - def _iter_optimizable_files(self, files: Sequence[FileInfo]) -> Iterable[FileInfo]: - for fi in files: + +class ImageOptimizationInsight(BaseImageOptimizationInsight): + """Analyse image optimisation opportunities in iOS apps.""" + + def _find_images(self, input: InsightsInput) -> List[FileInfo]: + images: List[FileInfo] = [] + for fi in input.file_analysis.files: if fi.file_type == "car": - yield from (c for c in fi.children if self._is_optimizable_image_file(c)) + images.extend(c for c in fi.children if self._is_optimizable_image_file(c)) elif self._is_optimizable_image_file(fi): - yield fi + images.append(fi) + return images def _is_optimizable_image_file(self, file_info: FileInfo) -> bool: if file_info.file_type.lower() not in self.OPTIMIZABLE_FORMATS: diff --git a/src/launchpad/size/models/apple.py b/src/launchpad/size/models/apple.py index 1dfc2489..f37e210e 100644 --- a/src/launchpad/size/models/apple.py +++ b/src/launchpad/size/models/apple.py @@ -70,6 +70,10 @@ class AppleAppInfo(BaseAppInfo): default_factory=list, description="List of code signature validation errors" ) main_binary_uuid: str | None = Field(None, description="UUID of the main binary") + primary_icon_name: str | None = Field(None, description="Primary app icon name from Info.plist") + alternate_icon_names: List[str] = Field( + default_factory=list, description="Alternate app icon names from Info.plist" + ) @dataclass @@ -160,6 +164,9 @@ class AppleInsightResults(BaseModel): unnecessary_files: UnnecessaryFilesInsightResult | None = Field(None, description="Unnecessary files analysis") audio_compression: AudioCompressionInsightResult | None = Field(None, description="Audio compression analysis") video_compression: VideoCompressionInsightResult | None = Field(None, description="Video compression analysis") + alternate_icons_optimization: ImageOptimizationInsightResult | None = Field( + None, description="Alternate app icons optimization analysis" + ) @dataclass diff --git a/src/launchpad/size/utils/file_analysis.py b/src/launchpad/size/utils/file_analysis.py index 4ef9c94d..d8c02561 100644 --- a/src/launchpad/size/utils/file_analysis.py +++ b/src/launchpad/size/utils/file_analysis.py @@ -279,7 +279,7 @@ def _analyze_asset_catalog(xcarchive: ZippedXCArchive, relative_path: Path) -> L full_path=element.full_path, path=str(relative_path / element.name), size=element.size, - file_type=(Path(element.full_path).suffix.lstrip(".") if element.full_path else "other"), + file_type=(Path(element.full_path or element.name).suffix.lstrip(".") or "other"), hash=file_hash, treemap_type=TreemapType.ASSETS, is_dir=False, diff --git a/tests/unit/test_alternate_icons_optimization.py b/tests/unit/test_alternate_icons_optimization.py new file mode 100644 index 00000000..0023ca31 --- /dev/null +++ b/tests/unit/test_alternate_icons_optimization.py @@ -0,0 +1,432 @@ +import tempfile + +from pathlib import Path +from unittest.mock import Mock + +from PIL import Image + +from launchpad.size.insights.apple.alternate_icons_optimization import AlternateIconsOptimizationInsight +from launchpad.size.insights.insight import InsightsInput +from launchpad.size.models.apple import AppleAppInfo +from launchpad.size.models.common import FileAnalysis, FileInfo, TreemapType +from launchpad.size.models.insights import ImageOptimizationInsightResult + + +class TestAlternateIconsOptimizationInsight: + def setup_method(self): + self.insight = AlternateIconsOptimizationInsight() + + def _create_test_png(self, size: tuple[int, int], quality: int = 100, optimized: bool = False) -> Path: + """Create a test PNG image and return its path.""" + temp_file = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + temp_path = Path(temp_file.name) + temp_file.close() + + # Create an image with some variety to ensure it has optimization potential + img = Image.new("RGB", size, color=(255, 0, 0)) + # Add some gradient to make the image less uniform + pixels = img.load() + for i in range(size[0]): + for j in range(size[1]): + pixels[i, j] = (255 - i % 256, j % 256, (i + j) % 256) + + img.save(temp_path, format="PNG", optimize=optimized, compress_level=0 if not optimized else 9) + + return temp_path + + def test_no_alternate_icons_returns_none(self): + """Test that insight is not generated when no alternate icons are defined.""" + app_info = AppleAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + executable="TestApp", + minimum_os_version="14.0", + primary_icon_name="AppIcon", + alternate_icon_names=[], # No alternate icons + ) + + files = [ + FileInfo( + full_path=Path("Assets.car"), + path="Assets.car", + size=1024000, + file_type="car", + treemap_type=TreemapType.ASSETS, + hash="hash_car", + is_dir=False, + children=[], + ), + ] + + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + assert result is None + + def test_non_apple_app_info_returns_none(self): + """Test that insight is not generated for non-Apple apps.""" + from launchpad.size.models.common import BaseAppInfo + + app_info = BaseAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + ) + + files = [] + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + assert result is None + + def test_identifies_alternate_icons(self): + """Test that alternate icons are correctly identified and analyzed.""" + # Create test images (larger and unoptimized to ensure savings > 4KB) + primary_icon_path = self._create_test_png((200, 200), optimized=False) + alt_icon1_path = self._create_test_png((200, 200), optimized=False) + alt_icon2_path = self._create_test_png((200, 200), optimized=False) + + try: + app_info = AppleAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + executable="TestApp", + minimum_os_version="14.0", + primary_icon_name="AppIcon", + alternate_icon_names=["DarkIcon", "LightIcon"], + ) + + # Simulate asset catalog with primary and alternate icons + files = [ + FileInfo( + full_path=Path("Assets.car"), + path="Assets.car", + size=500000, + file_type="car", + treemap_type=TreemapType.ASSETS, + hash="hash_car", + is_dir=False, + children=[ + # Primary icon - should be excluded + FileInfo( + full_path=primary_icon_path, + path="Assets.car/AppIcon-60@2x", + size=primary_icon_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash_primary", + is_dir=False, + ), + # Alternate icons - should be included + FileInfo( + full_path=alt_icon1_path, + path="Assets.car/DarkIcon-60@2x", + size=alt_icon1_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash_dark", + is_dir=False, + ), + FileInfo( + full_path=alt_icon2_path, + path="Assets.car/LightIcon-60@2x", + size=alt_icon2_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash_light", + is_dir=False, + ), + ], + ), + ] + + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + assert isinstance(result, ImageOptimizationInsightResult) + # Should only include alternate icons, not primary + assert len(result.optimizable_files) == 2 + assert result.total_savings > 0 + + # Verify correct icons are included + paths = [f.file_path for f in result.optimizable_files] + assert any("DarkIcon" in p for p in paths) + assert any("LightIcon" in p for p in paths) + assert not any("AppIcon" in p for p in paths) + + finally: + # Clean up test files + primary_icon_path.unlink(missing_ok=True) + alt_icon1_path.unlink(missing_ok=True) + alt_icon2_path.unlink(missing_ok=True) + + def test_excludes_primary_icon(self): + """Test that primary icon is excluded even if it has potential savings.""" + primary_icon_path = self._create_test_png((200, 200), optimized=False) + alt_icon_path = self._create_test_png((200, 200), optimized=False) + + try: + app_info = AppleAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + executable="TestApp", + minimum_os_version="14.0", + primary_icon_name="PrimaryIcon", + alternate_icon_names=["AlternateIcon"], + ) + + files = [ + FileInfo( + full_path=Path("Assets.car"), + path="Assets.car", + size=500000, + file_type="car", + treemap_type=TreemapType.ASSETS, + hash="hash_car", + is_dir=False, + children=[ + FileInfo( + full_path=primary_icon_path, + path="Assets.car/PrimaryIcon-60@2x", + size=primary_icon_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash_primary", + is_dir=False, + ), + FileInfo( + full_path=alt_icon_path, + path="Assets.car/AlternateIcon-60@2x", + size=alt_icon_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash_alt", + is_dir=False, + ), + ], + ), + ] + + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + if result: # May be None if savings are too small + # Ensure primary icon is not included + paths = [f.file_path for f in result.optimizable_files] + assert not any("PrimaryIcon" in p for p in paths) + + finally: + primary_icon_path.unlink(missing_ok=True) + alt_icon_path.unlink(missing_ok=True) + + def test_no_optimizable_icons_returns_none(self): + """Test that insight is not generated when alternate icons have no optimization opportunities.""" + app_info = AppleAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + executable="TestApp", + minimum_os_version="14.0", + primary_icon_name="AppIcon", + alternate_icon_names=["AlternateIcon"], + ) + + # No asset catalogs or icons + files = [ + FileInfo( + full_path=Path("Info.plist"), + path="Info.plist", + size=2048, + file_type="plist", + treemap_type=TreemapType.PLISTS, + hash="hash_plist", + is_dir=False, + ), + ] + + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + assert result is None + + def test_minimum_savings_threshold(self): + """Test that files below minimum savings threshold are excluded.""" + # Create a very small image that won't meet the 4KB savings threshold + small_icon_path = self._create_test_png((16, 16), quality=95) + + try: + app_info = AppleAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + executable="TestApp", + minimum_os_version="14.0", + primary_icon_name="AppIcon", + alternate_icon_names=["TinyIcon"], + ) + + files = [ + FileInfo( + full_path=Path("Assets.car"), + path="Assets.car", + size=10000, + file_type="car", + treemap_type=TreemapType.ASSETS, + hash="hash_car", + is_dir=False, + children=[ + FileInfo( + full_path=small_icon_path, + path="Assets.car/TinyIcon-16", + size=small_icon_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash_tiny", + is_dir=False, + ), + ], + ), + ] + + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + # Should return None as savings are below threshold + assert result is None + + finally: + small_icon_path.unlink(missing_ok=True) + + def test_icon_name_matching(self): + """Test that icon names are matched correctly with startswith logic.""" + icon1_path = self._create_test_png((200, 200), optimized=False) + icon2_path = self._create_test_png((200, 200), optimized=False) + icon3_path = self._create_test_png((200, 200), optimized=False) + + try: + app_info = AppleAppInfo( + name="TestApp", + version="1.0", + build="1", + app_id="com.test.app", + executable="TestApp", + minimum_os_version="14.0", + primary_icon_name="AppIcon", + alternate_icon_names=["CustomIcon"], + ) + + files = [ + FileInfo( + full_path=Path("Assets.car"), + path="Assets.car", + size=500000, + file_type="car", + treemap_type=TreemapType.ASSETS, + hash="hash_car", + is_dir=False, + children=[ + # Should match - starts with CustomIcon + FileInfo( + full_path=icon1_path, + path="Assets.car/CustomIcon-60", + size=icon1_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash1", + is_dir=False, + ), + # Should match - starts with CustomIcon + FileInfo( + full_path=icon2_path, + path="Assets.car/CustomIcon-60@2x", + size=icon2_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash2", + is_dir=False, + ), + # Should not match - different name + FileInfo( + full_path=icon3_path, + path="Assets.car/OtherIcon-60", + size=icon3_path.stat().st_size, + file_type="png", + treemap_type=TreemapType.ASSETS, + hash="hash3", + is_dir=False, + ), + ], + ), + ] + + file_analysis = FileAnalysis(files=files, directories=[]) + insights_input = InsightsInput( + app_info=app_info, + file_analysis=file_analysis, + treemap=Mock(), + binary_analysis=[], + ) + + result = self.insight.generate(insights_input) + + if result: + # Should only include CustomIcon variants + paths = [f.file_path for f in result.optimizable_files] + assert all("CustomIcon" in p for p in paths) + assert not any("OtherIcon" in p for p in paths) + + finally: + icon1_path.unlink(missing_ok=True) + icon2_path.unlink(missing_ok=True) + icon3_path.unlink(missing_ok=True)