Files
clide/test/kernel/src/i18n/i18n_test.dart
T
jpmschweitzerandClaude Opus 4.8 066197c5e1 fix(i18n): stop warning on missing tool.name.* keys for proper-name tools (T-493)
Tool display names are open-ended (Bash, Grep, Glob, ScheduleWakeup, MCP tools,
…) — they intentionally have no catalog key and fall back to the raw name, so a
miss isn't a gap to fix. The conversation pane was logging an i18n "missing key"
warning for each, cluttering the output dock.

Adds a `warnIfMissing` flag to I18nService.string (default true, so fixed UI
strings still warn on a real gap) threaded through the ClideSettings facade;
_toolNameLabel passes false. Display is unchanged — the placeholder already
rendered the raw name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:58:12 +02:00

251 lines
8.6 KiB
Dart

import 'dart:ui';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter_test/flutter_test.dart';
/// Fills fframe's test gap — every path through the lookup matrix is
/// exercised here.
void main() {
group('I18n lookup & fallback', () {
late Logger log;
setUp(() {
log = Logger(minLevel: LogLevel.error); // silence warnings in tests
});
I18n build({
required Map<String, Map<Locale, Map<String, Object?>>> catalogs,
Locale initial = const Locale('en', 'US'),
Locale defaultLocale = const Locale('en', 'US'),
}) {
final loader = InMemoryCatalogLoader(catalogs);
final i = I18n(
loader: loader,
log: log,
defaultLocale: defaultLocale,
initialLocale: initial,
availableLocales: const [Locale('en', 'US'), Locale('nl', 'NL')],
);
return i;
}
test('locale getters reflect constructor arguments', () {
final i = build(catalogs: const {}, initial: const Locale('nl', 'NL'), defaultLocale: const Locale('en', 'US'));
expect(i.currentLocale, const Locale('nl', 'NL'));
expect(i.defaultLocale, const Locale('en', 'US'));
expect(i.availableLocales, [const Locale('en', 'US'), const Locale('nl', 'NL')]);
});
test('missing key with placeholder returns placeholder', () async {
final i = build(catalogs: const {});
await i.ensureNamespaceLoaded('builtin.x');
expect(i.string('missing', namespace: 'builtin.x', placeholder: 'fallback'), 'fallback');
});
test('missing key with null placeholder returns the key', () async {
final i = build(catalogs: const {});
await i.ensureNamespaceLoaded('builtin.x');
expect(i.string('foo.bar', namespace: 'builtin.x'), 'foo.bar');
});
test('unknown namespace still returns placeholder (no crash)', () {
final i = build(catalogs: const {});
expect(i.string('k', namespace: 'not.registered', placeholder: 'fb'), 'fb');
});
test('exact locale hit beats fallback', () async {
final i = build(
catalogs: {
'builtin.x': {
const Locale('en', 'US'): {
'greet': {'translation': 'Hello'},
},
const Locale('en'): {
'greet': {'translation': 'Hi'},
},
},
},
);
await i.ensureNamespaceLoaded('builtin.x');
expect(i.string('greet', namespace: 'builtin.x', placeholder: 'fb'), 'Hello');
});
test('language-only locale hit falls through from exact', () async {
final i = build(
catalogs: {
'builtin.x': {
const Locale('nl'): {
'greet': {'translation': 'Hoi'},
},
},
},
initial: const Locale('nl', 'NL'),
);
await i.ensureNamespaceLoaded('builtin.x');
expect(i.string('greet', namespace: 'builtin.x', placeholder: 'fb'), 'Hoi');
});
test('falls through to default-locale when current locale is empty', () async {
final i = build(
catalogs: {
'builtin.x': {
const Locale('en', 'US'): {
'greet': {'translation': 'Hello'},
},
},
},
initial: const Locale('nl', 'NL'),
);
await i.ensureNamespaceLoaded('builtin.x');
expect(i.string('greet', namespace: 'builtin.x', placeholder: 'fb'), 'Hello');
});
test('interpolation replaces all replacers; missing ones silent', () async {
final i = build(
catalogs: {
'builtin.x': {
const Locale('en', 'US'): {
'welcome': {'translation': 'Hi {name} at {path}'},
},
},
},
);
await i.ensureNamespaceLoaded('builtin.x');
expect(
i.interpolated(
'welcome',
namespace: 'builtin.x',
placeholder: 'Hi {name} at {path}',
replacers: const [
I18nReplacer(from: '{name}', replace: 'Claude'),
// {path} deliberately absent
],
),
'Hi Claude at {path}',
);
});
test('namespace isolation — same key, different values', () async {
final i = build(
catalogs: {
'a': {
const Locale('en', 'US'): {
'k': {'translation': 'A'},
},
},
'b': {
const Locale('en', 'US'): {
'k': {'translation': 'B'},
},
},
},
);
await i.ensureNamespaceLoaded('a');
await i.ensureNamespaceLoaded('b');
expect(i.string('k', namespace: 'a', placeholder: '-'), 'A');
expect(i.string('k', namespace: 'b', placeholder: '-'), 'B');
});
test('setLocale refreshes cached namespaces and notifies listeners', () async {
final i = build(
catalogs: {
'x': {
const Locale('en', 'US'): {
'k': {'translation': 'Hello'},
},
const Locale('nl'): {
'k': {'translation': 'Hallo'},
},
},
},
);
await i.ensureNamespaceLoaded('x');
var notified = 0;
i.addListener(() => notified++);
await i.setLocale(const Locale('nl'));
expect(notified, greaterThanOrEqualTo(1));
expect(i.string('k', namespace: 'x', placeholder: '-'), 'Hallo');
});
test('registerCatalog merges third-party catalog', () async {
final i = build(catalogs: const {});
i.registerCatalog('ext.linear', const Locale('en', 'US'), const {
'issue.title': {'translation': 'Issues'},
});
expect(i.string('issue.title', namespace: 'ext.linear', placeholder: '-'), 'Issues');
});
test('unregisterCatalog forgets a namespace', () async {
final i = build(catalogs: const {});
i.registerCatalog('ext.x', const Locale('en', 'US'), const {
'k': {'translation': 'v'},
});
expect(i.string('k', namespace: 'ext.x', placeholder: '-'), 'v');
i.unregisterCatalog('ext.x');
expect(i.string('k', namespace: 'ext.x', placeholder: '-'), '-');
});
test('plain-string shape (no nested translation) is accepted', () async {
// Forward-compat: if a catalog later switches to `"k": "v"`
// instead of `"k": {"translation": "v"}`, lookup still works.
final i = build(
catalogs: {
'x': {
const Locale('en', 'US'): {'k': 'direct'},
},
},
);
await i.ensureNamespaceLoaded('x');
expect(i.string('k', namespace: 'x', placeholder: '-'), 'direct');
});
});
group('FallbackChain.resolve', () {
test('ordering: exact, lang, default, default-lang', () {
final chain = const FallbackChain(current: Locale('nl', 'NL'), defaultLocale: Locale('en', 'US')).resolve();
expect(chain.map((l) => l.toString()).toList(), ['nl_NL', 'nl', 'en_US', 'en']);
});
test('deduplicates when current == default', () {
final chain = const FallbackChain(current: Locale('en', 'US'), defaultLocale: Locale('en', 'US')).resolve();
expect(chain, ['en_US', 'en'].map((_) => isA<Locale>()));
expect(chain.length, 2);
});
test('filenameSuffix lowercases + omits empty country', () {
expect(FallbackChain.filenameSuffix(const Locale('en', 'US')), 'en_us');
expect(FallbackChain.filenameSuffix(const Locale('en')), 'en');
});
});
// T-493: open-ended lookups (tool display names like `tool.name.Bash`,
// `tool.name.ScheduleWakeup`, MCP tools) intentionally miss and fall back to
// the raw name, so they must not spam the warn log.
group('warnIfMissing (T-493)', () {
I18n withCapture(List<Object> warns) => I18n(
loader: InMemoryCatalogLoader(const {}),
log: Logger(minLevel: LogLevel.warn, sinks: [warns.add]),
defaultLocale: const Locale('en', 'US'),
);
test('a real gap warns by default, but warnIfMissing:false stays silent', () async {
final warns = <Object>[];
final i = withCapture(warns);
await i.ensureNamespaceLoaded('builtin.x');
expect(i.string('missing', namespace: 'builtin.x', placeholder: 'fb'), 'fb');
expect(warns, isNotEmpty, reason: 'a fixed UI string gap should still warn');
warns.clear();
expect(i.string('tool.name.Bash', namespace: 'builtin.x', placeholder: 'Bash', warnIfMissing: false), 'Bash');
expect(warns, isEmpty, reason: 'open-ended tool-name lookups stay quiet');
});
test('warnIfMissing:false also silences an unregistered namespace', () {
final warns = <Object>[];
expect(withCapture(warns).string('k', namespace: 'unregistered', placeholder: 'p', warnIfMissing: false), 'p');
expect(warns, isEmpty);
});
});
}