Skip to content

Commit 9067e31

Browse files
jkmasseloguzkocer
andauthored
Localization: stage translations into the String Catalog (manual) (#25713)
* Stage regular-string translations into Localizable.xcstrings Adds the localize half of the String Catalog pipeline: a manual (non-CI) lane that folds current GlotPress translations for regular, non-plural strings into the existing Localizable.xcstrings as `human ?? AI ?? English`. The catalog isn't the runtime store yet (the app still ships Localizable.strings), so this only pre-populates it for the eventual cutover — nothing users see changes. - `localize_catalog` lane: download current GlotPress translations into a throwaway dir, fold them in (human => `translated`), then AI-fill the rest (=> `needs_review`). Manual only — it calls the translation API (cost) and commits a large catalog; never wired into `download_localized_strings` or CI. - `CatalogStrings.fold_translations!`: pure, reuse-aware fold. An existing valid cell is kept, so re-runs only translate genuinely-new gaps — the catalog's `needs_review` state IS the persistence (no side-store). A human translation from GlotPress supersedes a kept cell on the next fold. - Immutable-key enforcement replaces `reconcile_changed_sources`: reworded English in place is now a hard error, since `xcstringstool sync` silently keeps the old key's translations and would ship stale text. Rewording requires a new key. - `translate_all` keeps completed batches when one fails mid-run, so a partial API failure degrades to English for the remainder instead of dropping the lot. - docs/localization-pipeline.md documents the regular-string fold and the `human ?? AI ?? English` floor — any translation, human or machine, that fails the placeholder gate falls through to the next rung. * Failing tests: placeholder gate rejects valid reordered human translations (#25802) * Add failing tests: placeholder gate rejects valid reordered human translations TranslationValidator compares a source's positional and sequential specifier views independently, so it only permits reordering when the source is ALREADY positional. A non-positional source (`%@ … %@`) whose translation reorders via positional specifiers (`%2$@ … %1$@`) is rejected — even though that is the standard, Apple-documented iOS reordering mechanism and is correct at runtime (`String(format:)` honors positional specifiers regardless of the source shape). The human-translation gate added for the String Catalog fold (`CatalogStrings.trusted_human`, `PluralStrings.human_forms_for`) inherits this, so it rejects and downgrades valid, currently-shipping human translations to machine/English. This is not hypothetical: the committed translated `.strings` contain 23 key-as-source strings across 34 locales (513 cells) that positionalize a bare-`%@` English source this way, e.g. ar "%@ of %@ used on your site" => "%1$@ من %2$@ على موقعك". Two failing tests pin the correct behavior: - translation_validator_test.rb — the root cause at the validator. - catalog_strings_helper_test.rb — the user-facing downgrade in the fold. The fix belongs in TranslationValidator (shared by AI, plurals, and regular human strings): treat a non-positional source as implicitly numbered 1..N by appearance order and accept a fully-positional candidate whose index→type map matches. Existing tests already keep genuine breakage (sequential flip, type/ count change) rejected, so a correct fix cannot just drop the distinction. * Fix placeholder gate: accept positional reorder of a non-positional source TranslationValidator compared a source's positional and sequential specifier views independently, so it only permitted reordering when the source was ALREADY positional. A non-positional source (`%@ … %@`) whose translation reorders via positional specifiers (`%2$@ … %1$@`) was rejected — even though that is the standard, Apple-documented iOS mechanism and is correct at runtime, because `String(format:)` honors positional specifiers regardless of the source's shape. Normalize a purely-sequential signature into the implicit 1..N positional map its bare specifiers already carry (`%@ %d` => `{1 => object, 2 => int}`) before comparing, applied to both source and candidate. Once both sides are index→type maps, `%@ - %@` and `%2$@ - %1$@` compare equal and the reorder is accepted. Genuine breakage stays rejected: a sequential flip `%@: %d` => `%d : %@` yields `{1 => object, 2 => int}` vs `{1 => int, 2 => object}` — a disagreement on which index owns which type — and type/length/count changes are unaffected. A signature that already uses positional specifiers, or one that mixes bare and positional specifiers (malformed), is left unchanged, so mixed strings still require an exact structural match. Fixes the two failing tests added in the previous commit and propagates to all three gate consumers (AI, plurals, regular human strings) via the shared validator. All 7 fastlane/lanes suites pass (113 tests); rubocop clean. * Add failing tests: gate and coverage compare miss format-argument edge cases Three currently-red tests documenting real defects found auditing the placeholder gate and the build-free coverage compare: - A candidate that binds the same positional index to two different types (%1$@ %1$d) is accepted: signature() collapses it last-wins, and this PR's implied_positional widened the reach to bare-specifier sources (%d), which regressed from reject to accept. - Dynamic field width/precision (%*d, %.*f) consume an extra int vararg the signature never counts, so %d and %*d reduce to the same signature. - coverage_gap conflates keys that differ only in argument type (%d days vs %@ days), masking a genuinely dropped key behind a same-prose sibling. * Fix placeholder gate: reject a positional index reused with a conflicting type signature() stored positional specifiers in a Hash keyed by index with plain assignment, so a second reference to the same index (`%1$@` then `%1$d`) silently overwrote the first, collapsing an object/int conflict to one token. A candidate that references argument 1 as both an object and an int passes an int to a `%@` conversion at runtime — a wrong-vararg read. This PR's implied_positional widened the reach: a bare-specifier source like `%d` now normalizes to {1 => int} and matched the collapsed candidate, regressing that case from reject to accept. Record a conflicting reuse as a distinct token so it can never equal a well-formed source's single type, while still allowing a consistent reuse (`%1$d … %1$d`, which legally reads one argument twice). * Fix placeholder gate: count dynamic field width/precision arguments A `*` in a specifier's field width or precision (`%*d`, `%.*f`) consumes its own int argument before the value, so `%d` and `%*d` are not interchangeable. The FORMAT_SPECIFIER regex matched the `*` but signature() emitted no token for it, so the two reduced to the same signature and a translation could add or drop a dynamic-width/precision argument undetected — reading one vararg past what the call site supplies. Emit an int argument for each `*`, in appearance order before the value. * Fix coverage compare: keep argument type when canonicalizing keys canonical() replaced every format specifier with one sentinel regardless of type, so keys differing only in argument type (`%d days` vs `%@ days`) collapsed to the same coverage cell — a genuinely-dropped key was masked by a same-prose sibling of a different type, defeating the same-basename overwrite regression the coverage gate exists to catch. Strip only the positional index (the sole difference between the genstrings source-form `%li` and the xcstringstool-normalized `%1$li` for the same source literal), so `%li` and `%1$li` still compare equal while `%d` and `%@` stay distinct. * Satisfy RuboCop: extract signature helpers and merge the catalog test class Danger's RuboCop pass flagged two offenses from the previous commits: - Metrics/AbcSize: signature() exceeded the ABC limit once it gained the positional-conflict and dynamic-width handling. Extract record_specifier and add_positional so signature() is a thin loop again; behavior unchanged. - Style/OneClassPerFile: the coverage_gap test added a second top-level class to catalog_helper_test.rb. Fold it into the file's single test class, renamed CatalogHelperTest since it now covers both reword detection and the coverage compare. --------- Co-authored-by: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> * Require ANTHROPIC_API_KEY for localize_catalog A keyless run has no AI tier, so every gap folds to an English placeholder and the lane would commit a dense, near-zero-value catalog (every translatable key × every locale materialized). Fail fast up front instead of staging it. The waste is specific to the keyless path — with AI on the same cells carry machine translations, and any English fallback is a sparse, self-healing "retry" signal the next run fills. fold_translations! stays nil-tolerant (unit-tested, and a future human-only flag would want it); only the lane is gated. Raised in review of the no-API-key run. --------- Co-authored-by: Oguz Kocer <oguzkocer@users.noreply.github.com>
1 parent 72dab72 commit 9067e31

13 files changed

Lines changed: 1167 additions & 113 deletions

docs/localization-pipeline.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
How user-facing strings get from English source into every shipped locale. This is the **release/tooling** view (the fastlane lanes under `fastlane/lanes/`); for how to *write* localizable strings in app code, see [localization.md](./localization.md).
44

5-
> The contract for every shipped string is **`human ?? AI ?? English`**: a human (GlotPress) translation if one exists, otherwise a machine translation, otherwise the English source. Nothing ships a broken placeholder — machine output that fails the format-specifier gate falls back to English.
5+
> The contract for every shipped string is **`human ?? AI ?? English`**: a human (GlotPress) translation if one exists, otherwise a machine translation, otherwise the English source. Nothing ships a broken placeholder — **any** translation, human or machine, that fails the format-specifier gate is rejected and falls through to the next rung.
66
77
## The round trip
88

@@ -45,13 +45,20 @@ The plural reverse-fold (`PluralStrings.fold_translations!`) fills each `(key, l
4545

4646
> **This does not ship machine translations yet.** `Plurals.xcstrings` is built into the app but **not consumed at runtime** — nothing reads the catalog, and nothing references its keys yet; the app still renders plurals the legacy way. The fold *pre-populates* the catalog so it's ready when plurals cut over to it. Until that cutover, the AI plural translations sit in the catalog unused.
4747
48-
## What's deferred: regular strings
48+
## What's staged, not shipped: regular strings
4949

50-
Regular (non-plural) strings are **not** machine-translated, by design. The app still ships the legacy `WordPress/Resources/<locale>.lproj/Localizable.strings` for them — `Localizable.xcstrings` (`generate_strings_catalog`) is the designated future backing store, but today it's only generated transiently in CI as a coverage check — the file is gitignored, not committed, and nothing ships from it. A machine translation written into the legacy `.strings` would be **live immediately**, and we don't want machine-translated regular strings shipping before the catalog cutover.
50+
Regular (non-plural) strings still ship the legacy way — from `WordPress/Resources/<locale>.lproj/Localizable.strings`, with no machine translation. A machine translation written there would be **live immediately**, and we don't want machine-translated regular strings shipping before the catalog cutover. `Localizable.xcstrings` (`generate_strings_catalog`) is the designated future backing store; it's gitignored and not a build member, so nothing ships from it.
5151

52-
So regular-string MT waits for the same shape as plurals: once `Localizable.xcstrings` becomes the runtime store, a regular-string **catalog reverse-fold** folds the human translations in and AI-fills the `needs_review` gaps, staged in the catalog (not shipped) until cutover — exactly as the plural fold does today.
52+
The tooling to **stage** regular-string translations into that catalog now exists, the same shape as the plural fold. `CatalogStrings.fold_translations!` fills each `(key, locale)` cell of `Localizable.xcstrings` as `human ?? AI ?? English` — human ⇒ `translated`, machine / English ⇒ `needs_review` — and is reuse-aware in the same way: a kept machine cell isn't re-translated, and a human translation supersedes it on the next fold. It runs as two manual lanes: `generate_strings_catalog` (extract the English source) and `localize_catalog` (download GlotPress into a throwaway temp dir, fold the humans in, AI-fill the rest, commit the catalog). The download is a fresh temp dir every run, so no stale/partial translation state is ever carried between runs.
5353

54-
When that's built, two facts established here will carry over:
54+
Two things keep it from shipping anything today, and both set it apart from the plural fold:
55+
56+
- **Manual, not in the release path.** These lanes aren't wired into `download_localized_strings` or any beta/release step — a run extracts strings, calls the API (cost), and commits a large catalog, so it's run on demand. Only the unit tests run in CI.
57+
- **Staged, not shipped.** `Localizable.xcstrings` still isn't the runtime store, so the folded translations sit in the catalog unused until the cutover — exactly like the plural catalog.
58+
59+
**Keys are immutable.** `generate_strings_catalog` hard-fails if an explicit-key string's English is reworded in place — `xcstringstool sync` would silently keep that key's now-stale translations, and the fold can't tell a translation of the old English from a current one. Rewording requires a **new key**. Key-as-source strings are exempt: rewording one changes the key, which sync handles as new/stale. (Enforcement today fires where the catalog persists; extending it to the transient-catalog CI path is a follow-up — a lint on the committed English `.strings`.)
60+
61+
Two facts the fold relies on, both established by the reverse download:
5562

5663
- **"Undefined by GlotPress" = absent**, not empty. The export omits untranslated strings (`status: current`; verified no empty-valued entries), so absence is the untranslated signal.
5764
- **Humans always supersede MT**, and machine output never returns to GlotPress — so there's no translation-memory pollution and no manual reconciliation, as long as MT lives in a state-bearing store (the catalog's `needs_review`).
@@ -77,5 +84,5 @@ When that's built, two facts established here will carry over:
7784
| Brand do-not-translate + per-locale terms | `fastlane/lanes/translation_glossary.rb` |
7885
| Anthropic SDK glue + Message Batches | `fastlane/lanes/anthropic_batch.rb` |
7986
| Plural fold (`Localizable.strings``Plurals.xcstrings`) + AI wiring | `fastlane/lanes/plural_strings_helper.rb`, `fastlane/lanes/localization_plurals.rb` |
80-
| Catalog generation (future regular-string backing store) | `fastlane/lanes/localization_catalog.rb` |
87+
| Catalog generation + regular-string fold (staged, manual) | `fastlane/lanes/localization_catalog.rb`, `fastlane/lanes/catalog_strings_helper.rb` |
8188
| Download/upload orchestration | `fastlane/lanes/localization.rb` |

fastlane/lanes/ai_translator.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,26 @@ def translate_plural(english_forms:, categories:, locale:, note: nil, anchors: {
160160
# strings already sorted by key so each batch naturally groups one feature (reader.*, editor.*) — better
161161
# terminology consistency within a batch.
162162
#
163+
# A batch spanning many keys is many sequential requests. If one FAILS mid-run (network / rate limit / SDK
164+
# error), the batches that already succeeded are KEPT and the run stops — the remaining strings fall back to
165+
# English and are retried on the next run, rather than the whole locale's completed (and billed) work being
166+
# discarded. Stopping (vs. continuing) avoids hammering the API when the failure is systemic.
167+
#
163168
# @param strings [Array<Hash>] each { key:, source:, comment: } (string or symbol keys both accepted).
164169
# @param locale [String] target lproj code.
165170
# @param batch_size [Integer] strings per request.
166171
def translate_all(strings, locale:, batch_size: DEFAULT_BATCH_SIZE)
167172
items = batchable_items(strings)
168173
return {} if items.empty?
169174

170-
items.each_slice(batch_size).with_object({}) do |chunk, out|
171-
out.merge!(translate_batch(chunk, locale))
175+
translated = {}
176+
items.each_slice(batch_size).with_index do |chunk, index|
177+
translated.merge!(translate_batch(chunk, locale))
178+
rescue StandardError => e
179+
warn_batch_failure(locale: locale, index: index, kept: translated.size, error: e)
180+
break
172181
end
182+
translated
173183
end
174184

175185
# Builds Message Batch jobs for many strings across many locales (the async / cheaper bulk path). Returns
@@ -297,6 +307,14 @@ def to_string_keys(hash)
297307
(hash || {}).each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
298308
end
299309

310+
# A batch request failed mid-run. Keep the batches that already succeeded and STOP rather than hammer the rest:
311+
# the untranslated strings fall back to English and are retried next run (the fold's reuse-awareness means the
312+
# kept ones aren't re-billed). Surfaced on stderr — this class is deliberately fastlane-free, so no UI here.
313+
def warn_batch_failure(locale:, index:, kept:, error:)
314+
warn "AITranslator: batch #{index + 1} for '#{locale}' failed (#{error.message}); " \
315+
"kept #{kept} translation(s), the rest fall back to English (retry next run)."
316+
end
317+
300318
# One batched request: number the chunk, ask for a JSON {number => translation}, keep the validated ones.
301319
def translate_batch(chunk, locale)
302320
numbered = number_chunk(chunk)

fastlane/lanes/ai_translator_test.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Uses a canned-reply lambda for `complete:`, so it exercises all of the prompt-building / validation logic
55
# without the `anthropic` gem or the network.
66
require 'minitest/autorun'
7+
require 'stringio'
78
require_relative 'ai_translator'
89

910
# Exercises prompt-building and the validator gate via a canned-reply `complete:` lambda (no gem / network).
@@ -17,6 +18,16 @@ def translator(reply:, prompts: nil)
1718
AITranslator.new(complete: complete)
1819
end
1920

21+
# Runs the block with $stderr captured, returning what it wrote (the class surfaces batch failures via warn).
22+
def capture_stderr
23+
original = $stderr
24+
$stderr = StringIO.new
25+
yield
26+
$stderr.string
27+
ensure
28+
$stderr = original
29+
end
30+
2031
def test_returns_cleaned_translation
2132
t = translator(reply: %("Réglages"\n)) # wrapped in quotes + trailing newline
2233
assert_equal 'Réglages', t.translate(source: 'Settings', locale: 'fr')
@@ -182,6 +193,37 @@ def test_translate_all_bad_json_batch_falls_back
182193
assert_empty out
183194
end
184195

196+
def test_translate_all_keeps_completed_batches_when_a_later_batch_fails
197+
calls = 0
198+
complete = lambda do |**|
199+
calls += 1
200+
raise 'rate limited' if calls == 2 # the second batch fails mid-run
201+
202+
'{"1":"x","2":"y"}'
203+
end
204+
out = nil
205+
warnings = capture_stderr do
206+
out = AITranslator.new(complete: complete).translate_all(
207+
[{ key: 'a', source: 'One' }, { key: 'b', source: 'Two' },
208+
{ key: 'c', source: 'Three' }, { key: 'd', source: 'Four' },
209+
{ key: 'e', source: 'Five' }, { key: 'f', source: 'Six' }],
210+
locale: 'fr', batch_size: 2
211+
)
212+
end
213+
214+
assert_equal 2, calls, 'must stop after the failing batch, not hammer the remaining ones'
215+
assert_equal({ 'a' => 'x', 'b' => 'y' }, out, 'the first completed batch is kept, not discarded')
216+
assert_match(/batch 2 for 'fr' failed \(rate limited\)/, warnings)
217+
end
218+
219+
def test_translate_all_returns_empty_without_raising_when_the_first_batch_fails
220+
complete = ->(**) { raise 'network down' }
221+
out = nil
222+
capture_stderr { out = AITranslator.new(complete: complete).translate_all([{ key: 'a', source: 'One' }], locale: 'fr') }
223+
224+
assert_empty out, 'a total failure degrades to English (empty), and must not propagate the exception'
225+
end
226+
185227
def test_translate_all_empty_input_makes_no_call
186228
called = false
187229
complete = lambda do |**|

fastlane/lanes/catalog_helper.rb

Lines changed: 24 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -24,75 +24,38 @@ def coverage_gap(reference, catalog_keys)
2424
reference.reject { |key| catalog_canonical.include?(canonical(key)) }
2525
end
2626

27-
# Collapse format specifiers to a single token so source-form (%li) and normalized (%1$li) compare equal.
27+
# Strip the positional index from each format specifier so a source-form specifier (%li) and its normalized form
28+
# (%1$li) compare equal, while specifiers of a DIFFERENT argument type stay distinct. The positional `N$` prefix
29+
# is the only thing that differs between the two extraction paths (genstrings vs xcstringstool) for the same
30+
# source literal; collapsing every specifier to one token — as this did before — conflated `%d days` with
31+
# `%@ days` and masked a genuinely-dropped key behind a same-prose sibling of a different type.
2832
def canonical(key)
29-
key.gsub(FORMAT_SPECIFIER, "\u0001")
33+
key.gsub(FORMAT_SPECIFIER) { |specifier| specifier.sub(/\A%\d+\$/, '%') }
3034
end
3135

32-
# --- needs_review reconciliation ----------------------------------------------------------------------
36+
# --- immutable-key enforcement -------------------------------------------------------------------------
3337

34-
35-
# `xcstringstool sync` does NOT reconcile an existing key whose English source VALUE changed: it leaves
36-
# both the stored English value and the affected translations untouched (verified — source "Settings" →
37-
# "Preferences" left en="Settings" and fr="translated"). The in-Xcode build does this reconciliation; the
38-
# standalone CLI does not. This closes that gap: where the freshly-extracted English differs from what the
39-
# catalog stores, it updates the English value and flips that key's translations from `translated` to
40-
# `needs_review` (so the AI/human pipeline re-checks them).
38+
# `xcstringstool sync` does NOT touch an existing key whose English source VALUE changed in place: it leaves
39+
# both the stored English and the affected translations as-is (verified — source "Settings" → "Preferences"
40+
# left en="Settings" and fr="translated"). The in-Xcode build reconciles this; the standalone CLI doesn't.
41+
#
42+
# Localization keys are IMMUTABLE, so an in-place reword is a rule violation, not something to reconcile:
43+
# rewording must mint a NEW key, otherwise the old key silently keeps translations of the OLD English — a
44+
# stale translation the fold can't distinguish from a current one. This reports the offending keys so the
45+
# lane can hard-fail (rename the key, or revert the English change).
4146
#
42-
# Out of scope here (handled elsewhere): English-as-key strings — editing their text changes the KEY, which
43-
# sync already handles as new/stale; and plural entries, whose English is itself a plural variation, so
44-
# `reconcile_entry!` bails (no flat English `stringUnit`) — those live in the separate plurals catalog.
45-
# Translation-side device/width variations of a regular string ARE reconciled (see `string_units`).
47+
# Naturally scoped to explicit-key strings: a key-as-source string has no stored `en` value (its key IS its
48+
# English), and rewording one changes the KEY — sync handles that as new/stale, not a value change — so it
49+
# never appears here. Plurals don't either (their English is a plural variation, not a flat `stringUnit`).
4650
#
47-
# @param catalog [Hash] parsed `.xcstrings`, mutated in place
51+
# @param catalog [Hash] parsed `.xcstrings`
4852
# @param current_en [Hash{String=>String}] key => freshly-extracted English value
49-
# @return [Array<String>] keys that were reconciled (English updated + translations re-flagged)
50-
def reconcile_source_changes!(catalog, current_en)
53+
# @return [Array<String>] keys whose stored English no longer matches the source (empty ⇒ nothing reworded)
54+
def reworded_keys(catalog, current_en)
5155
(catalog['strings'] || {}).filter_map do |key, entry|
52-
key if reconcile_entry!(entry, current_en[key])
53-
end
54-
end
55-
56-
# Reconcile one entry against its freshly-extracted English value. Returns the entry (truthy) if it
57-
# changed, nil otherwise — matching the Ruby bang-method convention (cf. String#gsub!).
58-
def reconcile_entry!(entry, new_value)
59-
return if new_value.nil?
60-
61-
english = entry.dig('localizations', 'en', 'stringUnit')
62-
return if english.nil? || english['value'] == new_value
63-
64-
english['value'] = new_value
65-
flag_translations_for_review!(entry['localizations'])
66-
entry
67-
end
68-
69-
def flag_translations_for_review!(localizations)
70-
localizations.each do |locale, body|
71-
next if locale == 'en' || body.nil?
72-
73-
string_units(body).each do |unit|
74-
unit['state'] = 'needs_review' if unit['state'] == 'translated'
75-
end
76-
end
77-
end
78-
79-
# All stringUnits in a localization body, whether stored flat (`stringUnit`) or nested under one or more
80-
# `variations` (a regular string's translation can be varied by device/width, and variations can nest).
81-
# Returns the unit hashes themselves so a caller can flip their `state` in place — a single top-level
82-
# `body['stringUnit']` lookup would miss the varied leaves entirely.
83-
def string_units(node)
84-
return [] unless node.is_a?(Hash)
85-
86-
units = []
87-
units << node['stringUnit'] if node['stringUnit'].is_a?(Hash)
88-
variations = node['variations']
89-
if variations.is_a?(Hash)
90-
variations.each_value do |cases|
91-
next unless cases.is_a?(Hash)
92-
93-
cases.each_value { |child| units.concat(string_units(child)) }
94-
end
56+
stored = entry.dig('localizations', 'en', 'stringUnit', 'value')
57+
fresh = current_en[key]
58+
key if stored && fresh && stored != fresh
9559
end
96-
units
9760
end
9861
end
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
# Pure-Ruby unit suite for CatalogHelper.reworded_keys — the immutable-key guard that detects an explicit-key
4+
# string whose English source was reworded in place (which `xcstringstool sync` silently ignores, leaving stale
5+
# translations). Run directly: `ruby fastlane/lanes/catalog_helper_test.rb`. No bundle / network.
6+
require 'minitest/autorun'
7+
require_relative 'catalog_helper'
8+
9+
# Exercises the reword detector (an explicit-key string whose stored English differs from a fresh extraction is
10+
# flagged; key-as-source, variations/plural, unchanged, and removed keys are not) and the coverage compare.
11+
class CatalogHelperTest < Minitest::Test
12+
def en(value)
13+
{ 'localizations' => { 'en' => { 'stringUnit' => { 'state' => 'translated', 'value' => value } } } }
14+
end
15+
16+
def reworded(strings, current_en)
17+
CatalogHelper.reworded_keys({ 'sourceLanguage' => 'en', 'version' => '1.0', 'strings' => strings }, current_en)
18+
end
19+
20+
def test_flags_an_explicit_key_whose_english_changed_in_place
21+
assert_equal ['common.save'], reworded({ 'common.save' => en('Save') }, { 'common.save' => 'Save all' })
22+
end
23+
24+
def test_does_not_flag_an_unchanged_key
25+
assert_empty reworded({ 'common.save' => en('Save') }, { 'common.save' => 'Save' })
26+
end
27+
28+
def test_does_not_flag_a_key_absent_from_the_fresh_extraction
29+
# The key was removed/renamed in code — sync handles that as stale; it is not an in-place reword.
30+
assert_empty reworded({ 'common.save' => en('Save') }, {})
31+
end
32+
33+
def test_does_not_flag_a_key_as_source_entry
34+
# No stored `en` value (the key IS the English); rewording it changes the key, so it can't appear here.
35+
assert_empty reworded({ '%1$@ on %2$@' => {} }, { '%1$@ on %2$@' => '%1$@ at %2$@' })
36+
end
37+
38+
def test_does_not_flag_a_variations_shaped_english
39+
# English stored under `variations` has no flat `en` stringUnit value, so it can never read as a reword.
40+
varied = { 'localizations' => { 'en' => { 'variations' => { 'device' => { 'iphone' => { 'stringUnit' => { 'state' => 'translated', 'value' => 'Tap' } } } } } } }
41+
assert_empty reworded({ 'app.banner' => varied }, { 'app.banner' => 'Click' })
42+
end
43+
44+
def test_reports_every_reworded_key
45+
strings = { 'a' => en('One'), 'b' => en('Two'), 'c' => en('Three') }
46+
assert_equal %w[b c], reworded(strings, { 'a' => 'One', 'b' => 'TWO', 'c' => 'THREE' }).sort
47+
end
48+
49+
# coverage_gap must not conflate keys that differ only in argument TYPE. `canonical` used to replace every format
50+
# specifier with one sentinel regardless of type/index, so if the build-free extraction loses "%d days" (e.g. a
51+
# same-basename .stringsdata overwrite — the exact regression this gate exists to catch), the surviving "%@ days"
52+
# masked it. "%d days" (int) and "%@ days" (object) are real, distinct en.lproj keys; dropping either is a hole.
53+
def test_coverage_gap_does_not_conflate_distinct_type_sibling_keys
54+
assert_equal ['%d days'], CatalogHelper.coverage_gap(['%d days', '%@ days'], ['%@ days'])
55+
end
56+
end

0 commit comments

Comments
 (0)