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
+16
View File
@@ -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"');
}
});
}
});
}
+17
View File
@@ -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'));
});
}
@@ -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<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final stream = StreamController<ConversationItem>.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());
+89
View File
@@ -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);
});
});
}
+5 -4
View File
@@ -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);
@@ -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<Object>('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
});
});
}
+28
View File
@@ -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);
});
});
}
@@ -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();
+64
View File
@@ -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');
});
}
@@ -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(