Skip to content

Commit 0706080

Browse files
SmallStrings insight fixes (#281)
* SmallStrings insight fixes * Update src/launchpad/size/models/insights.py Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * update tests * be more conservative * tests * tests --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 6b1c2cc commit 0706080

File tree

3 files changed

+146
-53
lines changed

3 files changed

+146
-53
lines changed
Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,41 @@
1+
import re
2+
3+
from pathlib import Path
4+
15
from launchpad.size.insights.insight import Insight, InsightsInput
2-
from launchpad.size.models.common import FileInfo
3-
from launchpad.size.models.insights import FileSavingsResult, LocalizedStringInsightResult
6+
from launchpad.size.models.insights import LocalizedStringInsightResult
47

58

69
class LocalizedStringsInsight(Insight[LocalizedStringInsightResult]):
7-
"""Insight for analyzing localized strings files in iOS apps."""
10+
"""Insight for analyzing localized strings files in iOS apps. If the total size of the localized strings files is greater than the threshold, we recommend using our SmallStrings library."""
11+
12+
SAVINGS_THRESHOLD_BYTES = 100 * 1024 # 100KB
813

9-
# 100KB threshold for reporting localized strings
10-
THRESHOLD_BYTES = 100 * 1024 # 100KB
14+
STRINGS_FILE_DENYLIST = ["LaunchScreen.strings"]
15+
16+
# Pattern to match .strings files directly inside .lproj directories
17+
# Matches: en.lproj/Localizable.strings, Base.lproj/InfoPlist.strings
18+
_LPROJ_STRINGS_PATTERN = re.compile(r"[^/]+\.lproj/[^/]+\.strings$")
1119

1220
def generate(self, input: InsightsInput) -> LocalizedStringInsightResult | None:
1321
"""Generate insight for localized strings files.
1422
1523
Finds all Localizable.strings files in *.lproj directories,
1624
calculates total size, and returns insight if above threshold.
1725
"""
18-
localized_files: list[FileInfo] = []
1926
total_size = 0
2027

21-
# Find all Localizable.strings files in *.lproj directories
28+
# Find all .strings files in *.lproj directories
2229
for file_info in input.file_analysis.files:
23-
# Check if file path ends with *.lproj/Localizable.strings
24-
if file_info.path.endswith(".lproj/Localizable.strings"):
25-
localized_files.append(file_info)
26-
total_size += file_info.size
27-
28-
if total_size > self.THRESHOLD_BYTES:
29-
file_savings = [FileSavingsResult(file_path=file.path, total_savings=file.size) for file in localized_files]
30+
if self._LPROJ_STRINGS_PATTERN.search(file_info.path):
31+
filename = Path(file_info.path).name
32+
if filename not in self.STRINGS_FILE_DENYLIST:
33+
total_size += file_info.size
3034

35+
small_strings_savings = int(total_size * 0.5)
36+
if small_strings_savings > self.SAVINGS_THRESHOLD_BYTES:
3137
return LocalizedStringInsightResult(
32-
files=file_savings,
33-
total_savings=total_size,
38+
total_savings=small_strings_savings,
3439
)
3540

3641
return None

src/launchpad/size/models/insights.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,11 @@ class WebPOptimizationInsightResult(FilesInsightResult):
105105
pass
106106

107107

108-
class LocalizedStringInsightResult(FilesInsightResult):
108+
class LocalizedStringInsightResult(BaseInsightResult):
109109
"""Results from localized string analysis.
110110
111-
Files contain localized strings files exceeding 100KB threshold with their sizes.
111+
Reports the total estimated savings that could be achieved by optimizing localized strings.
112+
112113
"""
113114

114115
pass

tests/unit/test_localized_strings_insight.py

Lines changed: 122 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def test_generate_with_large_localized_strings(self):
1818
localized_file_1 = FileInfo(
1919
full_path=Path("en.lproj/Localizable.strings"),
2020
path="en.lproj/Localizable.strings",
21-
size=60 * 1024, # 60KB
21+
size=150 * 1024, # 150KB
2222
file_type="strings",
2323
treemap_type=TreemapType.RESOURCES,
2424
hash="hash1",
@@ -27,7 +27,7 @@ def test_generate_with_large_localized_strings(self):
2727
localized_file_2 = FileInfo(
2828
full_path=Path("es.lproj/Localizable.strings"),
2929
path="es.lproj/Localizable.strings",
30-
size=50 * 1024, # 50KB
30+
size=60 * 1024, # 60KB
3131
file_type="strings",
3232
treemap_type=TreemapType.RESOURCES,
3333
hash="hash2",
@@ -56,21 +56,17 @@ def test_generate_with_large_localized_strings(self):
5656
result = self.insight.generate(insights_input)
5757

5858
assert isinstance(result, LocalizedStringInsightResult)
59-
assert len(result.files) == 2
60-
assert result.total_savings == 110 * 1024 # 110KB total
61-
assert result.files[0].file_path == "en.lproj/Localizable.strings"
62-
assert result.files[1].file_path == "es.lproj/Localizable.strings"
63-
# Verify savings match file sizes
64-
assert result.files[0].total_savings == 60 * 1024
65-
assert result.files[1].total_savings == 50 * 1024
59+
# Total savings should be 50% of the total file size (210KB * 0.5 = 105KB)
60+
expected_total_savings = int((150 + 60) * 1024 * 0.5)
61+
assert result.total_savings == expected_total_savings
6662

6763
def test_generate_with_small_localized_strings(self):
68-
"""Test that no insight is generated when total size is below 100KB threshold."""
69-
# Create localized strings files that don't exceed 100KB total
64+
"""Test that no insight is generated when estimated savings is below 100KB threshold."""
65+
# Create localized strings files where estimated savings (50%) don't exceed 100KB
7066
localized_file_1 = FileInfo(
7167
full_path=Path("en.lproj/Localizable.strings"),
7268
path="en.lproj/Localizable.strings",
73-
size=40 * 1024, # 40KB
69+
size=60 * 1024, # 60KB * 0.5 = 30KB savings
7470
file_type="strings",
7571
treemap_type=TreemapType.RESOURCES,
7672
hash="hash1",
@@ -79,7 +75,7 @@ def test_generate_with_small_localized_strings(self):
7975
localized_file_2 = FileInfo(
8076
full_path=Path("es.lproj/Localizable.strings"),
8177
path="es.lproj/Localizable.strings",
82-
size=30 * 1024, # 30KB
78+
size=50 * 1024, # 50KB * 0.5 = 25KB savings (total 55KB < 100KB threshold)
8379
file_type="strings",
8480
treemap_type=TreemapType.RESOURCES,
8581
hash="hash2",
@@ -97,14 +93,15 @@ def test_generate_with_small_localized_strings(self):
9793

9894
result = self.insight.generate(insights_input)
9995

100-
assert result is None # Should return None when below threshold
96+
assert result is None # Should return None when estimated savings below threshold
10197

10298
def test_generate_with_exactly_threshold_size(self):
103-
"""Test that insight is generated when total size exactly equals 100KB threshold."""
99+
"""Test that insight is generated when estimated savings exceeds 100KB threshold."""
100+
# Need 200KB to get exactly 100KB savings (200KB * 0.5 = 100KB)
104101
localized_file = FileInfo(
105102
full_path=Path("en.lproj/Localizable.strings"),
106103
path="en.lproj/Localizable.strings",
107-
size=100 * 1024, # Exactly 100KB
104+
size=200 * 1024, # 200KB * 0.5 = 100KB savings
108105
file_type="strings",
109106
treemap_type=TreemapType.RESOURCES,
110107
hash="hash1",
@@ -122,7 +119,7 @@ def test_generate_with_exactly_threshold_size(self):
122119

123120
result = self.insight.generate(insights_input)
124121

125-
assert result is None # Should return None when exactly at threshold
122+
assert result is None # Should return None when exactly at threshold (100KB, not >100KB)
126123

127124
def test_generate_with_no_localized_strings(self):
128125
"""Test that no insight is generated when no localized strings files exist."""
@@ -173,28 +170,37 @@ def test_generate_with_empty_file_list(self):
173170

174171
assert result is None
175172

176-
def test_generate_ignores_non_localizable_strings(self):
177-
"""Test that only Localizable.strings files are considered, not other .strings files."""
178-
localized_file = FileInfo(
173+
def test_generate_includes_all_strings_files_except_denylisted(self):
174+
"""Test that all .strings files in .lproj directories are considered, except denylisted ones."""
175+
localizable_file = FileInfo(
179176
full_path=Path("en.lproj/Localizable.strings"),
180177
path="en.lproj/Localizable.strings",
181-
size=150 * 1024, # 150KB - should trigger insight
178+
size=150 * 1024, # 150KB
182179
file_type="strings",
183180
treemap_type=TreemapType.RESOURCES,
184181
hash="hash1",
185182
is_dir=False,
186183
)
187-
other_strings_file = FileInfo(
188-
full_path=Path("en.lproj/Other.strings"),
189-
path="en.lproj/Other.strings",
190-
size=50 * 1024, # 50KB - should be ignored
184+
infoplist_file = FileInfo(
185+
full_path=Path("en.lproj/InfoPlist.strings"),
186+
path="en.lproj/InfoPlist.strings",
187+
size=100 * 1024, # 100KB - should be included
191188
file_type="strings",
192189
treemap_type=TreemapType.RESOURCES,
193190
hash="hash2",
194191
is_dir=False,
195192
)
193+
launchscreen_file = FileInfo(
194+
full_path=Path("en.lproj/LaunchScreen.strings"),
195+
path="en.lproj/LaunchScreen.strings",
196+
size=30 * 1024, # 30KB - should be ignored (in denylist)
197+
file_type="strings",
198+
treemap_type=TreemapType.RESOURCES,
199+
hash="hash3",
200+
is_dir=False,
201+
)
196202

197-
file_analysis = FileAnalysis(files=[localized_file, other_strings_file], directories=[])
203+
file_analysis = FileAnalysis(files=[localizable_file, infoplist_file, launchscreen_file], directories=[])
198204

199205
insights_input = InsightsInput(
200206
app_info=Mock(spec=BaseAppInfo),
@@ -206,17 +212,17 @@ def test_generate_ignores_non_localizable_strings(self):
206212
result = self.insight.generate(insights_input)
207213

208214
assert isinstance(result, LocalizedStringInsightResult)
209-
assert len(result.files) == 1
210-
assert result.files[0].file_path == "en.lproj/Localizable.strings"
211-
assert result.total_savings == 150 * 1024 # Only the Localizable.strings file
212-
assert result.files[0].total_savings == 150 * 1024
215+
# Should include localizable and infoplist, but not launchscreen
216+
# Total size: (150KB + 100KB) * 0.5 = 125KB savings
217+
expected_savings = int((150 + 100) * 1024 * 0.5)
218+
assert result.total_savings == expected_savings
213219

214220
def test_generate_ignores_non_lproj_localizable_strings(self):
215221
"""Test that Localizable.strings files outside .lproj directories are ignored."""
216222
valid_localized_file = FileInfo(
217223
full_path=Path("en.lproj/Localizable.strings"),
218224
path="en.lproj/Localizable.strings",
219-
size=150 * 1024, # 150KB - should be included
225+
size=250 * 1024, # 250KB - should be included
220226
file_type="strings",
221227
treemap_type=TreemapType.RESOURCES,
222228
hash="hash1",
@@ -244,7 +250,88 @@ def test_generate_ignores_non_lproj_localizable_strings(self):
244250
result = self.insight.generate(insights_input)
245251

246252
assert isinstance(result, LocalizedStringInsightResult)
247-
assert len(result.files) == 1
248-
assert result.files[0].file_path == "en.lproj/Localizable.strings"
249-
assert result.total_savings == 150 * 1024 # Only the valid file
250-
assert result.files[0].total_savings == 150 * 1024
253+
# Only the valid file should be included: 250KB * 0.5 = 125KB savings
254+
expected_savings = int(250 * 1024 * 0.5)
255+
assert result.total_savings == expected_savings
256+
257+
def test_regex_pattern_matching(self):
258+
"""Test that the regex pattern correctly matches .lproj/.strings files."""
259+
# Valid patterns that should match
260+
valid_files = [
261+
FileInfo(
262+
full_path=Path("en.lproj/Localizable.strings"),
263+
path="en.lproj/Localizable.strings",
264+
size=250 * 1024, # Large enough to trigger insight after 0.5 ratio
265+
file_type="strings",
266+
treemap_type=TreemapType.RESOURCES,
267+
hash="hash1",
268+
is_dir=False,
269+
),
270+
FileInfo(
271+
full_path=Path("Base.lproj/InfoPlist.strings"),
272+
path="Base.lproj/InfoPlist.strings",
273+
size=30 * 1024,
274+
file_type="strings",
275+
treemap_type=TreemapType.RESOURCES,
276+
hash="hash2",
277+
is_dir=False,
278+
),
279+
FileInfo(
280+
full_path=Path("pt-BR.lproj/Custom.strings"),
281+
path="pt-BR.lproj/Custom.strings",
282+
size=20 * 1024,
283+
file_type="strings",
284+
treemap_type=TreemapType.RESOURCES,
285+
hash="hash3",
286+
is_dir=False,
287+
),
288+
FileInfo(
289+
full_path=Path("some/path/en.lproj/file.strings"), # Valid: frameworks can have .lproj dirs
290+
path="some/path/en.lproj/file.strings",
291+
size=50 * 1024,
292+
file_type="strings",
293+
treemap_type=TreemapType.RESOURCES,
294+
hash="hash5",
295+
is_dir=False,
296+
),
297+
]
298+
299+
# Invalid patterns that should NOT match
300+
invalid_files = [
301+
FileInfo(
302+
full_path=Path("Localizable.strings"), # Not in .lproj
303+
path="Localizable.strings",
304+
size=50 * 1024,
305+
file_type="strings",
306+
treemap_type=TreemapType.RESOURCES,
307+
hash="hash4",
308+
is_dir=False,
309+
),
310+
FileInfo(
311+
full_path=Path("en.lproj/subdir/file.strings"), # Has subdirectory in .lproj
312+
path="en.lproj/subdir/file.strings",
313+
size=50 * 1024,
314+
file_type="strings",
315+
treemap_type=TreemapType.RESOURCES,
316+
hash="hash6",
317+
is_dir=False,
318+
),
319+
]
320+
321+
all_files = valid_files + invalid_files
322+
file_analysis = FileAnalysis(files=all_files, directories=[])
323+
324+
insights_input = InsightsInput(
325+
app_info=Mock(spec=BaseAppInfo),
326+
file_analysis=file_analysis,
327+
treemap=Mock(),
328+
binary_analysis=[],
329+
)
330+
331+
result = self.insight.generate(insights_input)
332+
333+
assert isinstance(result, LocalizedStringInsightResult)
334+
# Should include valid files: (250 + 30 + 20 + 50) * 1024 * 0.5 = 175KB
335+
# Note: some/path/en.lproj/file.strings is valid (frameworks can have .lproj dirs)
336+
expected_savings = int((250 + 30 + 20 + 50) * 1024 * 0.5)
337+
assert result.total_savings == expected_savings

0 commit comments

Comments
 (0)