diff --git a/test/a11y/i18n_coverage_test.dart b/test/a11y/i18n_coverage_test.dart index 8ac53f8e..ee2561e4 100644 --- a/test/a11y/i18n_coverage_test.dart +++ b/test/a11y/i18n_coverage_test.dart @@ -35,4 +35,20 @@ void main() { }); } }); + + // The bundled Dutch pack (T-462) must load and cover the same Tier-0 keys, so + // a locale switch never falls back to English for a built-in label. + group('i18n coverage — Dutch pack (nl-NL)', () { + for (final entry in referenced.entries) { + final ns = entry.key; + test('$ns nl_NL catalog covers every referenced key', () async { + final loader = AssetCatalogLoader(bundle: rootBundle); + final catalog = await loader.load(ns, const Locale('nl', 'NL')); + expect(catalog, isNotEmpty, reason: 'nl_NL catalog for "$ns" failed to load'); + for (final key in entry.value) { + expect(catalog.containsKey(key), isTrue, reason: 'nl_NL "$ns" missing key "$key"'); + } + }); + } + }); } diff --git a/test/app_test.dart b/test/app_test.dart index c1931986..f8c8bbea 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -453,4 +453,21 @@ void main() { await tester.pump(); expect(f.services.project.isOpen, isFalse); }); + + testWidgets('RootShell applies the persisted app.locale on boot (T-462)', (tester) async { + await tester.runAsync(() async => f.services.settings.set('app.locale', 'nl_NL')); + await pumpApp(tester); + expect(tester.takeException(), isNull); + // RootShell.initState parsed app.locale and called i18n.setLocale. + expect(f.services.i18n.currentLocale, const Locale('nl', 'NL')); + }); + + testWidgets('RootShell applies a bare language-only app.locale on boot (T-462)', (tester) async { + // A single-part locale ('nl', no region) exercises the Locale(parts[0]) + // branch in _applyLocale, distinct from the language_REGION form above. + await tester.runAsync(() async => f.services.settings.set('app.locale', 'nl')); + await pumpApp(tester); + expect(tester.takeException(), isNull); + expect(f.services.i18n.currentLocale, const Locale('nl')); + }); } diff --git a/test/builtin/claude/conversation_view_test.dart b/test/builtin/claude/conversation_view_test.dart index 175c6db6..9a1c6cc9 100644 --- a/test/builtin/claude/conversation_view_test.dart +++ b/test/builtin/claude/conversation_view_test.dart @@ -5,6 +5,7 @@ library; import 'dart:async'; +import 'dart:io'; import 'package:clide/builtin/claude/src/activity_cluster.dart'; import 'package:clide/builtin/claude/src/claude_banner.dart'; @@ -14,6 +15,7 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart'; import 'package:clide/builtin/claude/src/transcript_publisher.dart'; import 'package:clide/builtin/claude/src/transcript_reader.dart'; import 'package:clide/builtin/claude/src/workflow_run.dart'; +import 'package:clide/clide.dart' show IpcResponse; import 'package:clide/kernel/kernel.dart' show PaneKeyNav; import 'package:clide/kernel/src/events/message_bus.dart'; import 'package:clide/widgets/widgets.dart'; @@ -950,6 +952,90 @@ void main() { }); }); + // The file-ref open + live-tail handlers read project.current, so these need a + // real workspace open. project.open() spawns `git rev-parse` whose exit + // ReceivePort is trapped under the fake-async testWidgets zone (T-280) — so the + // open is done in setUp (real async), never inside a testWidgets body. + group('ConversationView with an open workspace', () { + late KernelFixture f; + late Directory proj; + + setUp(() async { + f = await KernelFixture.create(); + proj = await Directory.systemTemp.createTemp('clide_ws_'); + await Directory('${proj.path}/.git').create(); + await File('${proj.path}/lib/app.dart').create(recursive: true); + await File('${proj.path}/app.log').writeAsString('starting up\n'); + await f.services.project.open(proj.path); + }); + tearDown(() async { + await f.dispose(); + if (await proj.exists()) await proj.delete(recursive: true); + }); + + Future pumpWith(WidgetTester tester, List items) async { + tester.view.physicalSize = const Size(900, 700); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + final stream = StreamController.broadcast(); + final c = ConversationController(stream: stream.stream); + addTearDown(c.dispose); + await tester.pumpWidget( + harness( + f, + Builder( + builder: (ctx) => MediaQuery( + data: MediaQuery.of(ctx).copyWith(disableAnimations: true), + child: ConversationView(controller: c, foldLevel: FoldLevel.none), + ), + ), + ), + ); + for (final it in items) { + stream.add(it); + } + await tester.pumpAndSettle(); + return c; + } + + testWidgets('clicking a repo file ref opens it in the editor (T-300)', (tester) async { + // Capture the editor.open IPC the file-ref tap fires. + String? openedPath; + int? openedLine; + f.ipc.stub('editor.open', (args) async { + openedPath = args['path'] as String?; + openedLine = args['line'] as int?; + return IpcResponse.ok(id: '1', data: {'path': args['path']}); + }); + + await pumpWith(tester, [_user('crash at lib/app.dart:42 today')]); + await tester.tap(find.text('lib/app.dart:42')); + await tester.pumpAndSettle(); + + // _resolveRepoFile resolved the ref against project.current, _openFile sent + // the absolute path + line to editor.open. + expect(openedPath, '${proj.path}/lib/app.dart'); + expect(openedLine, 42); + }); + + testWidgets('a tail Bash card follows a real workspace file (T-325)', (tester) async { + // The tail command names a single file inside the repo → a followable + // source, so _BashLiveTail mounts a terminal instead of the muted note. + await pumpWith(tester, [ + AssistantToolUse(uuid: 'bt', timestamp: _t, isSidechain: false, toolUseId: 'tbt', name: 'Bash', input: const {'command': 'tail -f app.log'}), + ]); + await tester.tap(find.bySemanticsLabel('Bash, 1 step, collapsed')); + await tester.pumpAndSettle(); + + // A resolvable source → the live tail surfaced, NOT the "nothing" note. + expect(find.text('live tail'), findsOneWidget); + expect(find.text('no independent source to follow'), findsNothing); + }); + }); + group('ClaudeBanner', () { late KernelFixture f; setUp(() async => f = await KernelFixture.create()); diff --git a/test/builtin/claude/prompt_card_test.dart b/test/builtin/claude/prompt_card_test.dart index 19f13cae..9767d1e5 100644 --- a/test/builtin/claude/prompt_card_test.dart +++ b/test/builtin/claude/prompt_card_test.dart @@ -611,4 +611,93 @@ void main() { expect(d, isNull, reason: 'typing in the note must not trigger Allow'); }); }); + + group('Enter / number-key on questions (T-240)', () { + testWidgets('Enter submits a single answered question (_activatePrimary, q.length<=1)', (tester) async { + ToolDecision? d; + await tester.pumpWidget(harness(f, ToolPromptCard(prompt: questionPrompt(), onResolve: (_, x) => d = x))); + await tester.pump(); // autofocus + + // Enter with nothing chosen → gated, no resolve. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(d, isNull, reason: 'Submit is gated until an option is picked'); + + // Choose, then Enter → submits the single-question answer. + await tester.tap(find.textContaining('Dogs')); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect((d as AllowTool).updatedInput['answers']['Do you prefer cats or dogs?'], 'Dogs'); + }); + + testWidgets('Enter advances a mid-step question then submits at review (_activatePrimary multi)', (tester) async { + ToolDecision? d; + await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, x) => d = x))); + await tester.pump(); // autofocus + + // Number key picks an option on the current (first) question — exercises + // _currentQuestion returning _step in a multi-question prompt. + await tester.sendKeyEvent(LogicalKeyboardKey.digit2); // Dogs + await tester.pump(); + // Enter on a mid (non-last, non-review) step advances to question 2. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(find.text('How eaten?'), findsOneWidget); + + // Answer #2 via number key, Enter → last step advances to review. + await tester.sendKeyEvent(LogicalKeyboardKey.digit1); // Fresh + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + expect(find.text('Review your answers'), findsOneWidget); + + // Enter on the review step submits both answers. + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(); + final answers = (d as AllowTool).updatedInput['answers'] as Map; + expect(answers['Which pet?'], 'Dogs'); + expect(answers['How eaten?'], 'Fresh'); + }); + }); + + group('Back navigation in the multi-question stepper', () { + testWidgets('Back on a mid-step returns to the previous question (_step--)', (tester) async { + await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, _) {}))); + await tester.pump(); + + await tester.tap(find.textContaining('Dogs')); + await tester.pump(); + await tester.tap(find.text('Next ›')); + await tester.pump(); + expect(find.text('How eaten?'), findsOneWidget); + + // On step 2 a Back button is present (only shown when _step > 0). + await tester.tap(find.text('‹ Back')); + await tester.pump(); + expect(find.text('Which pet?'), findsOneWidget); + expect(find.text('How eaten?'), findsNothing); + }); + + testWidgets('Back on the review step returns to the last question (_step = length-1)', (tester) async { + await tester.pumpWidget(harness(f, ToolPromptCard(prompt: twoQuestionPrompt(), onResolve: (_, _) {}))); + await tester.pump(); + + await tester.tap(find.textContaining('Dogs')); + await tester.pump(); + await tester.tap(find.text('Next ›')); + await tester.pump(); + await tester.tap(find.textContaining('Fresh')); + await tester.pump(); + await tester.tap(find.text('Review ›')); + await tester.pump(); + expect(find.text('Review your answers'), findsOneWidget); + + // The review screen's Back button steps back to the last question. + await tester.tap(find.text('‹ Back')); + await tester.pump(); + expect(find.text('How eaten?'), findsOneWidget); + expect(find.text('Review your answers'), findsNothing); + }); + }); } diff --git a/test/builtin/menubar/menu_bar_test.dart b/test/builtin/menubar/menu_bar_test.dart index 3d5fe1fe..63ae7c58 100644 --- a/test/builtin/menubar/menu_bar_test.dart +++ b/test/builtin/menubar/menu_bar_test.dart @@ -97,12 +97,13 @@ void main() { testWidgets('tapping the same top button toggles the menu closed', (tester) async { await tester.pumpWidget(harness()); await tester.pump(); + // Capture the top button's point while the tree is stable (menu closed), + // then tapAt it to toggle closed — re-finding 'File' after the overlay opens + // can hit a transiently-relaid element and throw in getCenter under load. + final fileCenter = tester.getCenter(find.text('File')); await openMenu(tester, 'File'); expect(find.text('Open Folder…'), findsOneWidget); - // Let the open overlay finish laying out before re-tapping the top button, - // so its hit-test box is stable under load (getCenter would otherwise throw). - await tester.pumpAndSettle(); - await tester.tap(find.text('File')); + await tester.tapAt(fileCenter); await tester.pump(); await tester.pump(const Duration(milliseconds: 20)); // flush the close under load expect(find.text('Open Folder…'), findsNothing); diff --git a/test/builtin/settings_ui/category_view_test.dart b/test/builtin/settings_ui/category_view_test.dart index c229be95..2a7d41b3 100644 --- a/test/builtin/settings_ui/category_view_test.dart +++ b/test/builtin/settings_ui/category_view_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:clide/builtin/settings_ui/settings_ui.dart'; import 'package:clide/clide.dart' show IpcResponse; import 'package:clide/extension/extension.dart'; @@ -193,6 +195,37 @@ void main() { expect(f.services.settings.effectiveLayer('app.demo.flag'), isNull); }); + testWidgets('a value stored at project scope shows the This project tag', (tester) async { + // project.* keys live only in the project layer, which needs a project dir + // open. Point the store at a temp dir and write the key there at project + // scope so _appearance(SettingsScope.project) renders (lines 560-562). + const projCat = SettingsCategory( + id: 'pj', + title: 'PJ', + sections: [ + SettingsSection( + label: 'S', + fields: [SettingsField(key: 'project.pj.flag', kind: SettingsFieldKind.toggle, label: 'ProjFlag', defaultValue: false)], + ), + ], + ); + late Directory projDir; + await tester.runAsync(() async { + projDir = await Directory.systemTemp.createTemp('clide_scope_'); + await f.services.settings.setProjectDir(projDir); + await f.services.settings.setAt(SettingsScope.project, 'project.pj.flag', true); + }); + addTearDown(() async { + if (await projDir.exists()) { + try { + await projDir.delete(recursive: true); + } catch (_) {} + } + }); + await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: projCat)))); + expect(find.bySemanticsLabel('ProjFlag scope: This project'), findsOneWidget); + }); + testWidgets('moving an unset value to All clide writes it at app scope', (tester) async { // _other's key (app.other.x) is written by no other test → pristine here. await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: _other)))); @@ -269,6 +302,28 @@ void main() { expect(f.services.settings.get('app.nr.size'), isNot('abc')); }); + testWidgets('a text field reflects an external value change when not focused (didUpdateWidget)', (tester) async { + // Dedicated key — the store is shared across tests in this file. + const extCat = SettingsCategory( + id: 'tx', + title: 'TX', + sections: [ + SettingsSection( + label: 'S', + fields: [SettingsField(key: 'app.tx.name', kind: SettingsFieldKind.text, label: 'Name', defaultValue: 'initial')], + ), + ], + ); + await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: extCat)))); + expect(find.text('initial'), findsOneWidget); + // External write (e.g. a reset or another surface) with the field unfocused + // → the ListenableBuilder rebuilds _EditControl with a new value, and + // didUpdateWidget pushes it into the controller (lines 433-438). + await tester.runAsync(() => f.services.settings.set('app.tx.name', 'updated')); + await tester.pump(); + expect(find.text('updated'), findsOneWidget); + }); + testWidgets('a text field commits the trimmed value', (tester) async { await tester.pumpWidget(harness(f, _bounded(const SettingsCategoryView(category: textCat)))); await tester.enterText(find.byType(EditableText), ' hello '); @@ -372,4 +427,65 @@ void main() { expect(find.text('1'), findsOneWidget); }); }); + + group('i18n localization (T-462)', () { + testWidgets('section/field/help labels resolve through the category namespace', (tester) async { + // KernelFixture.create does real temp-dir I/O; run it on the real event + // loop (not the fake-async testWidgets body) or it traps under load (T-122). + late KernelFixture lf; + await tester.runAsync( + () async => lf = await KernelFixture.create( + i18nCatalogs: { + 'test.loc': { + const Locale('en', 'US'): const { + 'sec.label': {'translation': 'LOCSEC'}, + 'fld.label': {'translation': 'LocField'}, + 'fld.help': {'translation': 'LocHelp'}, + 'sel.label': {'translation': 'LocSelect'}, + 'opt.a': {'translation': 'LocOptA'}, + }, + }, + }, + ), + ); + addTearDown(lf.dispose); + const cat = SettingsCategory( + id: 'loc', + title: 'EngCat', + i18nNamespace: 'test.loc', + sections: [ + SettingsSection( + label: 'EngSec', + labelKey: 'sec.label', + fields: [ + SettingsField( + key: 'app.loc.flag', + kind: SettingsFieldKind.toggle, + label: 'EngField', + labelKey: 'fld.label', + help: 'EngHelp', + helpKey: 'fld.help', + defaultValue: false, + ), + SettingsField( + key: 'app.loc.sel', + kind: SettingsFieldKind.select, + label: 'EngSelect', + labelKey: 'sel.label', + defaultValue: 'a', + options: [SettingsOption(value: 'a', label: 'EngOptA', labelKey: 'opt.a')], + ), + ], + ), + ], + ); + await tester.pumpWidget(harness(lf, _bounded(const SettingsCategoryView(category: cat)))); + expect(find.text('LOCSEC'), findsOneWidget); // section header from the catalog + expect(find.text('LocField'), findsOneWidget); // field label localized + expect(find.text('LocHelp'), findsOneWidget); // help localized + expect(find.text('LocSelect'), findsOneWidget); // select field label localized + expect(find.text('LocOptA'), findsOneWidget); // select shows the localized current option + expect(find.text('EngField'), findsNothing); // English placeholder replaced + }); + }); } diff --git a/test/builtin/settings_ui/widget_test.dart b/test/builtin/settings_ui/widget_test.dart index 3d48ff32..422a1355 100644 --- a/test/builtin/settings_ui/widget_test.dart +++ b/test/builtin/settings_ui/widget_test.dart @@ -23,6 +23,12 @@ void main() { 'panel.empty': {'translation': 'No settings categories are registered yet.'}, }, }, + // Namespace for a category whose rail title resolves via titleKey. + 'test.railcat': { + const Locale('en', 'US'): const { + 'cat.title': {'translation': 'Localized Rail'}, + }, + }, }, ); }); @@ -64,5 +70,27 @@ void main() { await tester.pump(); expect(dismissed, 1); }); + + testWidgets('a rail row resolves its title via titleKey + namespace (T-462)', (tester) async { + // A category with titleKey + i18nNamespace → the rail localizes the row + // title through the catalog instead of the English fallback (line 251). + f.services.settingsRegistry.register( + const SettingsCategory( + id: 'railcat', + title: 'EngRail', + titleKey: 'cat.title', + i18nNamespace: 'test.railcat', + sections: [ + SettingsSection( + label: 'S', + fields: [SettingsField(key: 'app.railcat.flag', kind: SettingsFieldKind.toggle, label: 'F', defaultValue: false)], + ), + ], + ), + ); + await tester.pumpWidget(harness(f, SettingsModal(onDismiss: () {}))); + expect(find.text('Localized Rail'), findsOneWidget); + expect(find.text('EngRail'), findsNothing); + }); }); } diff --git a/test/kernel/src/extensions_manager_test.dart b/test/kernel/src/extensions_manager_test.dart index 77bb0cc1..2a055403 100644 --- a/test/kernel/src/extensions_manager_test.dart +++ b/test/kernel/src/extensions_manager_test.dart @@ -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(); diff --git a/test/widgets/clide_settings_test.dart b/test/widgets/clide_settings_test.dart index 7d7c162c..0c0ce8f7 100644 --- a/test/widgets/clide_settings_test.dart +++ b/test/widgets/clide_settings_test.dart @@ -1,3 +1,5 @@ +import 'package:clide/clide.dart' show IpcResponse; +import 'package:clide/extension/extension.dart' show CommandContribution; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,4 +42,66 @@ void main() { expect(mono, 'FiraMono'); expect(ui, 'Inter'); }); + + testWidgets('i18n.string returns the placeholder without a kernel (T-462)', (tester) async { + late String s; + await tester.pumpWidget( + Builder( + builder: (context) { + s = ClideSettings.i18n.string(context, 'k', namespace: 'x', placeholder: 'Fallback'); + return const SizedBox(); + }, + ), + ); + expect(s, 'Fallback'); + }); + + testWidgets('i18n.interpolated applies replacers to the placeholder without a kernel', (tester) async { + late String s; + await tester.pumpWidget( + Builder( + builder: (context) { + s = ClideSettings.i18n.interpolated( + context, + 'k', + namespace: 'x', + placeholder: 'Hi {name}', + replacers: [I18nReplacer(from: '{name}', replace: 'Jeroen')], + ); + return const SizedBox(); + }, + ), + ); + expect(s, 'Hi Jeroen'); + }); + + testWidgets('localizedCommandTitle resolves titleKey, else falls back to the title (T-462)', (tester) async { + final withKey = CommandContribution( + id: 'a', + command: 'a', + title: 'Eng A', + titleKey: 'cmd.a', + i18nNamespace: 'x', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ); + final noKey = CommandContribution( + id: 'b', + command: 'b', + title: 'Eng B', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ); + late String a; + late String b; + await tester.pumpWidget( + Builder( + builder: (context) { + a = localizedCommandTitle(context, withKey); // no kernel → placeholder (the title) + b = localizedCommandTitle(context, noKey); + return const SizedBox(); + }, + ), + ); + expect(a, 'Eng A'); + expect(b, 'Eng B'); + }); } diff --git a/test/widgets/zero_coverage_widgets_test.dart b/test/widgets/zero_coverage_widgets_test.dart index 637ca297..90cc6f0b 100644 --- a/test/widgets/zero_coverage_widgets_test.dart +++ b/test/widgets/zero_coverage_widgets_test.dart @@ -162,6 +162,38 @@ void main() { expect(find.text('Hoverable'), findsOneWidget); }); + testWidgets('hovering a NON-selected palette row paints the hover background', (tester) async { + // The first row is selected (selectedIndex 0), so its color comes from the + // `selected` branch. Hovering the SECOND (unselected) row exercises the + // `_hover ? listItemHoverBackground` arm that the selected row skips. + f.services.commands.register( + CommandContribution( + id: 'h1', + command: 'first.cmd', + title: 'FirstRow', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ), + ); + f.services.commands.register( + CommandContribution( + id: 'h2', + command: 'second.cmd', + title: 'SecondRow', + run: (_) async => IpcResponse.ok(id: '', data: const {}), + ), + ); + f.services.palette.open(); + await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()]))); + await tester.pumpAndSettle(); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await gesture.addPointer(location: Offset.zero); + await gesture.moveTo(tester.getCenter(find.text('SecondRow'))); + await tester.pumpAndSettle(); + expect(find.text('SecondRow'), findsOneWidget); + }); + testWidgets('arrow keys move the highlighted command (T-100)', (tester) async { for (final id in ['a', 'b', 'c']) { f.services.commands.register(