test(i18n): cover the i18n facade, selector, manifest routing + locale (T-462)

Tests for the epic's new code so the 95% floor holds: ClideSettings.i18n
string/interpolated null-safe + localizedCommandTitle; settings-renderer
localization (section/field/help/select/file, project-scope tag, didUpdateWidget);
extension-activation rollback + settings-contribution unregister; the Dutch
gate check; RootShell applying app.locale on boot; and prompt/conversation
tool-body + file-ref coverage. Also harden the menu toggle re-tap (capture the
button point while stable, then tapAt) so it no longer throws getCenter under
the serial coverage load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:26:30 +02:00
co-authored by Claude Opus 4.8
parent 328902a3d8
commit 9d7ab35a19
10 changed files with 550 additions and 4 deletions
@@ -295,6 +295,31 @@ void main() {
expect(f.services.keybindings.commandFor(Keybinding.parse('ctrl+alt+j')), isNull);
});
test('Settings category + control contributions register on activate and unregister on deactivate', () async {
f.services.extensions.register(
_Ext(
id: 'settings-ext',
contributions: const [
SettingsCategoryContribution(
id: 'settings-ext.cat',
category: SettingsCategory(id: 'sx', title: 'SX', sections: []),
),
SettingsControlContribution(id: 'settings-ext.ctl', customId: 'sx.control', builder: _buildNothing),
],
),
);
await f.services.extensions.activateAll();
// Both contributions are applied (category via SettingsRegistry.register,
// control via SettingsControlRegistry.register — lines 268-272).
expect(f.services.settingsRegistry.byId('sx')?.title, 'SX');
expect(f.services.settingsControlRegistry.builderFor('sx.control'), isNotNull);
await f.services.extensions.deactivate('settings-ext');
// Deactivation unwinds both via _removeContribution (lines 293-296).
expect(f.services.settingsRegistry.byId('sx'), isNull);
expect(f.services.settingsControlRegistry.builderFor('sx.control'), isNull);
});
test('all getter yields every registered extension', () async {
f.services.extensions.register(_Ext(id: 'a-iter'));
f.services.extensions.register(_Ext(id: 'b-iter'));
@@ -405,6 +430,76 @@ void main() {
expect(f.services.extensions.isActivated('base'), isFalse, reason: 'allowed once the dependent is gone');
});
test('a throw AFTER activate() succeeds unwinds mounted contributions AND runs deactivate()', () async {
// activate() succeeds (so extActivated is set), the first command
// contribution mounts, then a mid-list duplicate command id throws in
// _applyContribution — exercising the rollback path: the already-applied
// contribution is unwound and the extension's deactivate() is called
// because its own activate() had succeeded (lines 184-200).
var deactivated = false;
f.services.extensions.register(
_Ext(
id: 'two-cmds-second-dupes',
contributions: [
CommandContribution(
id: 'first-ok',
command: 'rollback.first',
run: (_) async => IpcResponse.ok(id: ''),
),
CommandContribution(
id: 'second-dupe',
command: 'rollback.first',
run: (_) async => IpcResponse.ok(id: ''),
),
],
onActivate: (_) async {}, // succeeds → extActivated = true
onDeactivate: () async => deactivated = true,
),
);
await f.services.extensions.activateAll();
// Rolled back: not activated, marked failed, and the already-mounted
// first command is gone (the unwind ran _removeContribution on it).
expect(f.services.extensions.isActivated('two-cmds-second-dupes'), isFalse);
expect(f.services.extensions.didFail('two-cmds-second-dupes'), isTrue);
expect(f.services.commands.get('rollback.first'), isNull, reason: 'the first command must be unwound');
// activate() had succeeded → deactivate() ran during rollback.
expect(deactivated, isTrue);
// failedExtensions getter exposes the recorded error for the UI badge.
expect(f.services.extensions.failedExtensions.containsKey('two-cmds-second-dupes'), isTrue);
});
test('a deactivate() that throws DURING rollback is caught and logged', () async {
// activate() succeeds, the first command mounts, the duplicate throws,
// and the rollback-triggered deactivate() ALSO throws — the inner catch
// swallows + logs it rather than masking the original failure (line 197).
f.services.extensions.register(
_Ext(
id: 'rollback-deactivate-throws',
contributions: [
CommandContribution(
id: 'rdt.first',
command: 'rdt.cmd',
run: (_) async => IpcResponse.ok(id: ''),
),
CommandContribution(
id: 'rdt.second',
command: 'rdt.cmd',
run: (_) async => IpcResponse.ok(id: ''),
),
],
onActivate: (_) async {}, // succeeds → extActivated = true
onDeactivate: () async => throw StateError('teardown also fails'),
),
);
await f.services.extensions.activateAll();
// Original failure still wins: not activated, marked failed, unwound.
expect(f.services.extensions.isActivated('rollback-deactivate-throws'), isFalse);
expect(f.services.extensions.didFail('rollback-deactivate-throws'), isTrue);
expect(f.services.commands.get('rdt.cmd'), isNull, reason: 'the first command must still be unwound');
});
test('a duplicate contribution id fails the second activation', () async {
f.services.extensions
..register(
@@ -431,3 +526,5 @@ void main() {
}
void _noop() {}
Widget _buildNothing(BuildContext _) => const SizedBox.shrink();