Skip to content

Commit 4f97b86

Browse files
authored
Merge pull request #2421 from SatoshiPortal/arb-tool
add tool for working with loc (arb) files
2 parents 9cce60d + ff728df commit 4f97b86

8 files changed

Lines changed: 1526 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ CLAUDE.md
115115
# sentry-native build artifacts pulled by sentry_flutter on Linux
116116
.sentry-native/
117117

118-
# Generated integration-test aggregator (see tool/gen_all_test.dart)
118+
# Generated integration-test aggregator (see tools/gen_all_test.dart)
119119
integration_test/all_test.dart
120120

121121
# Generated swap debug log export (written during test runs)

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ Workflow when you need a widget:
171171
3. **If genuinely new and reused by ≥ 2 features**, put it in `lib/core/widgets/<category>/` from the start — that *is* growing the UI Kit.
172172
4. **If used by exactly one feature**, it lives in `<feature>/ui/widgets/` — but write it composable enough to be promoted later (no hardcoded colors, no hardcoded text, take callbacks not bloc refs).
173173
5. **Widgets never live under `adapters/`, `frameworks/`, `domain/`, or `application/`.** UI goes in `ui/` or `lib/core/widgets/`. Full stop.
174-
6. **No hardcoded user-facing strings.** Always `context.loc.<key>` — the `BuildContext` extension (`build_context_x.dart`) that wraps `AppLocalizations.of(context)`; it is the dominant convention (≈2564 uses vs 3 raw `AppLocalizations.of`). Add the key to [`localization/`](localization/) and run `make translations`. A duplicated literal across screens means a missing l10n key.
174+
6. **No hardcoded user-facing strings.** Always `context.loc.<key>` — the `BuildContext` extension (`build_context_x.dart`) that wraps `AppLocalizations.of(context)`; it is the dominant convention (≈2564 uses vs 3 raw `AppLocalizations.of`). Manage keys in [`localization/`](localization/) with [`tools/arb.dart`](tools/README.md) (`fvm dart run tools/arb.dart help`) — don't hand-edit the `.arb` files — then run `make translations`. A duplicated literal across screens means a missing l10n key.
175175
7. **Theme tokens only** — colors, spacing, typography pulled from the theme. See rule #10 above.
176176

177177
When you spot a duplicate of an existing core widget in feature code, flag it in the PR description as a follow-up cleanup. Don't silently leave it. Don't fix unrelated duplicates in the same PR either — that breaks atomic commits.

integration_test/coins_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import 'package:flutter_test/flutter_test.dart';
3333
// wallet's real UTXOs and asserts the confirmations/labels fields the view
3434
// renders. Needs a funded testnet wallet → skipped when absent.
3535
//
36-
// Run via `make integration-test` (auto-aggregated by tool/gen_all_test.dart).
36+
// Run via `make integration-test` (auto-aggregated by tools/gen_all_test.dart).
3737
Future<void> main({bool isInitialized = false}) async {
3838
TestWidgetsFlutterBinding.ensureInitialized();
3939
if (!isInitialized) await Bull.init();

makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,11 @@ unit-test:
278278
# invocation, so running this one file builds + launches once for the whole
279279
# suite (instead of failing every file but the first, as `flutter test
280280
# integration_test/` does). all_test.dart is a generated, gitignored artifact —
281-
# tool/gen_all_test.dart regenerates it from disk below, so adding a test file
281+
# tools/gen_all_test.dart regenerates it from disk below, so adding a test file
282282
# needs no manual wiring.
283283
integration-test:
284284
@echo "🧪 integration tests"
285-
@fvm dart run tool/gen_all_test.dart
285+
@fvm dart run tools/gen_all_test.dart
286286
@fvm flutter test integration_test/all_test.dart --reporter=expanded
287287

288288
# Build & render the bull_ui design-system catalogue (Widgetbook) locally in the

test/tools/arb_test.dart

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:path/path.dart' as p;
6+
7+
/// End-to-end tests for `tools/arb.dart`.
8+
///
9+
/// The tool is deliberately not parameterised: it hardcodes the `localization/`
10+
/// directory relative to the current working directory. So rather than import
11+
/// it, each test spins up a throwaway fixture dir with a `localization/` folder
12+
/// and runs the real script as a subprocess with that dir as CWD — exercising
13+
/// exactly the binary a developer/agent invokes. The whole value of the tool is
14+
/// "never corrupt these files", so the load-bearing assertion throughout is the
15+
/// byte-for-byte comparison of untouched content.
16+
void main() {
17+
// Resolve the script once. The test file lives at test/tools/, the script at
18+
// tools/arb.dart, both under the repo root.
19+
final repoRoot = _findRepoRoot();
20+
final script = p.join(repoRoot, 'tools', 'arb.dart');
21+
// Under `flutter test`, Platform.resolvedExecutable is the flutter_tester,
22+
// not a Dart VM — spawning the script against it would hang. Resolve a real
23+
// Dart binary from the SDK that ships beside it instead.
24+
final dart = _resolveDart();
25+
26+
late Directory tmp;
27+
28+
setUp(() {
29+
tmp = Directory.systemTemp.createTempSync('arb_tool_test_');
30+
Directory(p.join(tmp.path, 'localization')).createSync();
31+
});
32+
33+
tearDown(() => tmp.deleteSync(recursive: true));
34+
35+
/// Writes a locale file verbatim and returns its path.
36+
String writeLocale(String locale, String content) {
37+
final path = p.join(tmp.path, 'localization', 'app_$locale.arb');
38+
File(path).writeAsStringSync(content);
39+
return path;
40+
}
41+
42+
String readLocale(String locale) => File(
43+
p.join(tmp.path, 'localization', 'app_$locale.arb'),
44+
).readAsStringSync();
45+
46+
/// Runs the tool with [args] in the fixture dir. Uses the Dart VM directly
47+
/// (no `dart run`) so there are no per-call package build hooks — fast and
48+
/// hermetic.
49+
ProcessResult run(List<String> args) =>
50+
Process.runSync(dart, [script, ...args], workingDirectory: tmp.path);
51+
52+
// A minimal but representative template: a translated key with metadata, a
53+
// plain key, and a key carrying placeholders — mirroring real .arb shape.
54+
const enTemplate =
55+
'{\n'
56+
' "@@locale": "en",\n'
57+
' "greeting": "Hello",\n'
58+
' "@greeting": {\n'
59+
' "description": "a greeting"\n'
60+
' },\n'
61+
' "count": "{n, plural, =1{1 item} other{{n} items}}",\n'
62+
' "@count": {\n'
63+
' "placeholders": {\n'
64+
' "n": {\n'
65+
' "type": "int"\n'
66+
' }\n'
67+
' }\n'
68+
' },\n'
69+
' "farewell": "Bye"\n'
70+
'}\n';
71+
72+
const frTemplate =
73+
'{\n'
74+
' "@@locale": "fr",\n'
75+
' "greeting": "Bonjour"\n'
76+
'}\n';
77+
78+
group('validate', () {
79+
test('accepts well-formed files', () {
80+
writeLocale('en', enTemplate);
81+
writeLocale('fr', frTemplate);
82+
final r = run(['validate']);
83+
expect(r.exitCode, 0, reason: r.stderr.toString());
84+
expect(r.stdout, contains('app_en.arb: ok'));
85+
});
86+
87+
test('rejects a file whose layout does not match the parsed JSON', () {
88+
// Valid JSON, but the nested "nested" field is indented at two spaces
89+
// instead of four — exactly the reflowed-metadata corruption the invariant
90+
// guards against. It reads as an extra top-level key line, so the line-key
91+
// count no longer matches the JSON key count and the tool must refuse.
92+
writeLocale(
93+
'en',
94+
'{\n "@@locale": "en",\n "a": {\n "nested": "x"\n },\n "b": "2"\n}\n',
95+
);
96+
final r = run(['validate']);
97+
expect(r.exitCode, 1);
98+
});
99+
});
100+
101+
group('add', () {
102+
test('is byte-identical after a matching delete (round-trip)', () {
103+
writeLocale('en', enTemplate);
104+
writeLocale('fr', frTemplate);
105+
final enBefore = readLocale('en');
106+
final frBefore = readLocale('fr');
107+
108+
final add = run([
109+
'add',
110+
'newKey',
111+
'--translations',
112+
jsonEncode({'en': 'New', 'fr': 'Nouveau'}),
113+
'--description',
114+
'brand new',
115+
]);
116+
expect(add.exitCode, 0, reason: add.stderr.toString());
117+
expect(readLocale('en'), isNot(enBefore));
118+
expect(readLocale('fr'), isNot(frBefore));
119+
120+
final del = run(['delete', 'newKey']);
121+
expect(del.exitCode, 0, reason: del.stderr.toString());
122+
expect(readLocale('en'), enBefore, reason: 'en not restored byte-exact');
123+
expect(readLocale('fr'), frBefore, reason: 'fr not restored byte-exact');
124+
});
125+
126+
test('produces valid JSON with the new value and metadata', () {
127+
writeLocale('en', enTemplate);
128+
run([
129+
'add',
130+
'newKey',
131+
'--translations',
132+
jsonEncode({'en': 'New'}),
133+
'--description',
134+
'brand new',
135+
]);
136+
final map = jsonDecode(readLocale('en')) as Map<String, dynamic>;
137+
expect(map['newKey'], 'New');
138+
expect((map['@newKey'] as Map)['description'], 'brand new');
139+
// The previously-last key must have gained a comma without breaking JSON.
140+
expect(map['farewell'], 'Bye');
141+
});
142+
143+
test('gives the prior last key a comma (no trailing-comma corruption)', () {
144+
writeLocale('en', enTemplate);
145+
run([
146+
'add',
147+
'newKey',
148+
'--translations',
149+
jsonEncode({'en': 'New'}),
150+
]);
151+
final lines = readLocale('en').split('\n');
152+
final farewell = lines.firstWhere((l) => l.contains('"farewell"'));
153+
expect(farewell.trimRight().endsWith(','), isTrue);
154+
});
155+
156+
test('refuses to overwrite an existing key and writes nothing', () {
157+
writeLocale('en', enTemplate);
158+
final before = readLocale('en');
159+
final r = run([
160+
'add',
161+
'greeting',
162+
'--translations',
163+
jsonEncode({'en': 'x'}),
164+
]);
165+
// Exit 1 (ArbException): a state conflict, distinct from the exit-64
166+
// usage errors above (bad key, bad locale, bad placeholder shape).
167+
expect(r.exitCode, 1);
168+
expect(readLocale('en'), before);
169+
});
170+
171+
test('rejects a non-camelCase key before writing', () {
172+
writeLocale('en', enTemplate);
173+
final before = readLocale('en');
174+
final r = run([
175+
'add',
176+
'BadKey',
177+
'--translations',
178+
jsonEncode({'en': 'x'}),
179+
]);
180+
expect(r.exitCode, 64);
181+
expect(readLocale('en'), before);
182+
});
183+
184+
test('rejects an unknown locale and leaves all files untouched', () {
185+
writeLocale('en', enTemplate);
186+
final before = readLocale('en');
187+
final r = run([
188+
'add',
189+
'newKey',
190+
'--translations',
191+
jsonEncode({'en': 'x', 'zz': 'y'}),
192+
]);
193+
expect(r.exitCode, 64);
194+
expect(readLocale('en'), before);
195+
expect(
196+
File(p.join(tmp.path, 'localization', 'app_zz.arb')).existsSync(),
197+
isFalse,
198+
);
199+
});
200+
201+
test('rejects a malformed placeholder shape', () {
202+
writeLocale('en', enTemplate);
203+
final before = readLocale('en');
204+
final r = run([
205+
'add',
206+
'newKey',
207+
'--translations',
208+
jsonEncode({'en': 'x'}),
209+
'--placeholders',
210+
jsonEncode({'n': 'int'}), // should be {"type":"int"}
211+
]);
212+
expect(r.exitCode, 64);
213+
expect(readLocale('en'), before);
214+
});
215+
});
216+
217+
group('set', () {
218+
test('updates an existing locale value in place', () {
219+
writeLocale('en', enTemplate);
220+
writeLocale('fr', frTemplate);
221+
final r = run(['set', 'greeting', 'fr', 'Coucou']);
222+
expect(r.exitCode, 0, reason: r.stderr.toString());
223+
final fr = jsonDecode(readLocale('fr')) as Map<String, dynamic>;
224+
expect(fr['greeting'], 'Coucou');
225+
});
226+
227+
test('refuses a key absent from the template', () {
228+
writeLocale('en', enTemplate);
229+
writeLocale('fr', frTemplate);
230+
final before = readLocale('fr');
231+
final r = run(['set', 'ghost', 'fr', 'x']);
232+
expect(r.exitCode, 64);
233+
expect(readLocale('fr'), before);
234+
});
235+
});
236+
237+
group('rename', () {
238+
test('preserves value and metadata across locales', () {
239+
writeLocale('en', enTemplate);
240+
writeLocale('fr', frTemplate);
241+
final r = run(['rename', 'greeting', 'salutation']);
242+
expect(r.exitCode, 0, reason: r.stderr.toString());
243+
244+
final en = jsonDecode(readLocale('en')) as Map<String, dynamic>;
245+
expect(en.containsKey('greeting'), isFalse);
246+
expect(en['salutation'], 'Hello');
247+
expect((en['@salutation'] as Map)['description'], 'a greeting');
248+
249+
final fr = jsonDecode(readLocale('fr')) as Map<String, dynamic>;
250+
expect(fr['salutation'], 'Bonjour');
251+
});
252+
253+
test('refuses when the new key already exists', () {
254+
writeLocale('en', enTemplate);
255+
final before = readLocale('en');
256+
final r = run(['rename', 'greeting', 'farewell']);
257+
expect(r.exitCode, 1);
258+
expect(readLocale('en'), before);
259+
});
260+
});
261+
262+
group('--dry-run', () {
263+
test('reports intended writes but changes nothing on disk', () {
264+
writeLocale('en', enTemplate);
265+
writeLocale('fr', frTemplate);
266+
final enBefore = readLocale('en');
267+
final frBefore = readLocale('fr');
268+
final r = run([
269+
'add',
270+
'newKey',
271+
'--translations',
272+
jsonEncode({'en': 'New', 'fr': 'Nouveau'}),
273+
'--dry-run',
274+
]);
275+
expect(r.exitCode, 0, reason: r.stderr.toString());
276+
expect(r.stderr, contains('[dry-run]'));
277+
expect(readLocale('en'), enBefore);
278+
expect(readLocale('fr'), frBefore);
279+
});
280+
});
281+
282+
group('option validation', () {
283+
test(
284+
'rejects an option that is valid globally but wrong for the command',
285+
() {
286+
writeLocale('en', enTemplate);
287+
final r = run(['get', 'greeting', '--description', 'x']);
288+
expect(r.exitCode, 64);
289+
expect(r.stderr, contains('Unknown option'));
290+
},
291+
);
292+
293+
test('rejects an outright unknown option', () {
294+
writeLocale('en', enTemplate);
295+
final r = run(['missing', '--lst']);
296+
expect(r.exitCode, 64);
297+
});
298+
});
299+
}
300+
301+
/// Finds a real Dart executable to run the script with. `flutter test` sets
302+
/// [Platform.resolvedExecutable] to the flutter_tester, so we look for the
303+
/// `dart` (or `dart.exe`) that ships in the same SDK's `bin/`, walking up from
304+
/// the tester until a `dart-sdk/bin/dart` or a sibling `dart` is found.
305+
String _resolveDart() {
306+
final exe = Platform.isWindows ? 'dart.exe' : 'dart';
307+
// 1) Sibling of the resolved executable (covers `dart test`).
308+
final sibling = p.join(p.dirname(Platform.resolvedExecutable), exe);
309+
if (File(sibling).existsSync() &&
310+
p.basenameWithoutExtension(sibling) == 'dart') {
311+
return sibling;
312+
}
313+
// 2) The dart-sdk bundled under a Flutter cache: walk up looking for it.
314+
var dir = Directory(p.dirname(Platform.resolvedExecutable));
315+
while (true) {
316+
final candidate = p.join(dir.path, 'cache', 'dart-sdk', 'bin', exe);
317+
if (File(candidate).existsSync()) return candidate;
318+
final nested = p.join(dir.path, 'dart-sdk', 'bin', exe);
319+
if (File(nested).existsSync()) return nested;
320+
final parent = dir.parent;
321+
if (parent.path == dir.path) break;
322+
dir = parent;
323+
}
324+
// 3) Last resort: hope `dart` is on PATH.
325+
return exe;
326+
}
327+
328+
/// Walks up from this test file to the directory that contains `tools/arb.dart`.
329+
String _findRepoRoot() {
330+
var dir = Directory.current;
331+
while (true) {
332+
if (File(p.join(dir.path, 'tools', 'arb.dart')).existsSync()) {
333+
return dir.path;
334+
}
335+
final parent = dir.parent;
336+
if (parent.path == dir.path) {
337+
throw StateError('Could not locate repo root from ${Directory.current}');
338+
}
339+
dir = parent;
340+
}
341+
}

0 commit comments

Comments
 (0)