Files
clide/test/kernel/src/keymap/keymap_service_test.dart
T
jpmschweitzerandClaude Opus 4.7 12e0509fa3
test / unit + widget + golden + a11y (push) Failing after 28s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 1m5s
keyboard-operable ClideTappable + palette nav (T-100)
Plug widgets into the keymap layer landed in T-117.

ClideTappable:
  - Wrap in `Actions(ActivateIntent → onTap)` outside a `Focus` so
    dispatch from the focused context walks up and hits the action.
  - Add a focus ring via `tokens.globalFocus` (DecoratedBox foreground
    overlay, transparent border when unfocused, no layout shift).
  - Disabled (`onTap == null`) skips focus traversal and shows the
    forbidden cursor.

ClidePalette:
  - Register Actions for the four palette intents
    (selectNext / selectPrev / accept / dismiss).
  - Publish `palette.open` scope flag via `KeymapService.setScopeFlag`
    so when-clauses can scope future bindings to "palette only".
  - Highlight the selected row with `listItemSelectedBackground`;
    scroll it into view on nav.
  - `PaletteController` grows `selectedIndex` + `selectNext` /
    `selectPrevious` / `acceptSelected`; index resets on open /
    filter change.

Intents.dart drops the `ClideIntent` base — `ActivateIntent` and
`DismissIntent` come from Flutter; clide owns the palette and text-
scale and command-bridge subclasses. `parseIntentId('activate')` →
Flutter's class; same for dismiss. Widget code uses the canonical
Flutter Intent types where they fit.

App root grows a PaletteOpenIntent action that calls
`services.palette.open()`, completing the ctrl/cmd+shift+p path
end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:48:03 +02:00

287 lines
11 KiB
Dart

/// Tests for the KeymapService layering + scope context + resolve.
library;
import 'dart:io';
import 'package:clide/kernel/src/keymap/intents.dart';
import 'package:clide/kernel/src/keymap/key_chord.dart';
import 'package:clide/kernel/src/keymap/keymap_service.dart';
import 'package:clide/kernel/src/settings.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ActivateIntent, DismissIntent;
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory appDir;
late SettingsStore settings;
setUp(() async {
appDir = await Directory.systemTemp.createTemp('clide_keymap_test_');
settings = SettingsStore(appDir: appDir);
await settings.load();
});
tearDown(() async {
settings.dispose();
if (await appDir.exists()) await appDir.delete(recursive: true);
});
group('load()', () {
test('loads the default preset from the asset bundle', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap, isNotNull);
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
});
test('honours the app.keymap.preset setting', () async {
await settings.set<String>(kKeymapPresetSetting, 'vscode');
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/vscode.yaml': 'name: vscode\nbindings:\n - intent: palette.open\n keys: ctrl+shift+p\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<PaletteOpenIntent>());
});
test('missing preset asset is tolerated (active keymap is empty)', () async {
final svc = KeymapService(settings: settings, appDir: appDir, bundle: _bundle(const {}));
await svc.load();
expect(svc.keymap, isNotNull);
expect(svc.keymap!.effectiveBindings, isEmpty);
});
test('layers a user file on top of the preset', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString(
'name: user\nbindings:\n - intent: activate\n keys: ctrl+p\n',
);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: palette.open\n keys: ctrl+p\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<ActivateIntent>());
});
test('layers a settings overlay above the user file', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString(
'name: user\nbindings:\n - intent: activate\n keys: ctrl+p\n',
);
await settings.set<List<Object?>>(kKeymapOverridesSetting, [
{'intent': 'dismiss', 'keys': 'ctrl+p'},
]);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: palette.open\n keys: ctrl+p\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+p'), const {}), isA<DismissIntent>());
});
test('tolerates a malformed user file by ignoring it', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString('not: real keymap [yaml');
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
});
test('tolerates a malformed settings overlay entry by ignoring just the overlay', () async {
await settings.set<List<Object?>>(kKeymapOverridesSetting, [
{'intent': 'definitely.not.real', 'keys': 'ctrl+p'},
]);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
});
});
group('registerCommandBinding / unregisterCommandBindings', () {
test('adds an InvokeCommandIntent that resolves after load', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+shift+g', 'git.commit');
final intent = svc.keymap!.resolve(KeyChord.parse('ctrl+shift+g'), const {});
expect(intent, isA<InvokeCommandIntent>());
final invoke = intent as InvokeCommandIntent;
expect(invoke.commandId, 'git.commit');
});
test('honours a when-clause on the contribution', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+s', 'editor.save', when: 'editor.focused');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+s'), const {}), isNull);
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+s'), {'editor.focused': true}), isA<InvokeCommandIntent>());
});
test('user file overrides a contributed binding for the same chord', () async {
await File('${appDir.path}/keybindings.yaml').writeAsString(
'name: user\nbindings:\n - intent: dismiss\n keys: ctrl+x\n',
);
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+x', 'editor.cut');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isA<DismissIntent>());
});
test('unregisterCommandBindings removes prior contributions', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.registerCommandBinding('ctrl+x', 'editor.cut');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isA<InvokeCommandIntent>());
svc.unregisterCommandBindings('editor.cut');
expect(svc.keymap!.resolve(KeyChord.parse('ctrl+x'), const {}), isNull);
});
test('unregister of an unknown command is a no-op', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.unregisterCommandBindings('nothing-registered'); // doesn't throw
});
});
group('scope flags', () {
test('setScopeFlag updates the context; notifies listeners on change', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
var notified = 0;
svc.addListener(() => notified++);
svc.setScopeFlag('palette.open', true);
expect(svc.scope['palette.open'], isTrue);
expect(notified, 1);
svc.setScopeFlag('palette.open', true); // no-op
expect(notified, 1);
svc.setScopeFlag('palette.open', false);
expect(notified, 2);
});
test('clearScopeFlag removes the entry; no-op when absent', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({'assets/keymaps/default.yaml': 'name: default\nbindings: []\n'}),
);
await svc.load();
svc.setScopeFlag('foo', true);
svc.clearScopeFlag('foo');
expect(svc.scope.containsKey('foo'), isFalse);
svc.clearScopeFlag('foo'); // no-op
});
});
group('setPreset', () {
test('switches presets and reloads', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
'assets/keymaps/vim.yaml': 'name: vim\nbindings:\n - intent: activate\n keys: escape\n',
}),
);
await svc.load();
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
await svc.setPreset('vim');
expect(settings.get<String>(kKeymapPresetSetting), 'vim');
expect(svc.keymap!.resolve(KeyChord.parse('escape'), const {}), isA<ActivateIntent>());
});
});
group('resolveEvent', () {
test('returns null before load()', () {
final svc = KeymapService(settings: settings, appDir: appDir, bundle: _bundle(const {}));
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
);
expect(svc.resolveEvent(down, HardwareKeyboard.instance), isNull);
});
test('returns the bound intent for a matched chord', () async {
final svc = KeymapService(
settings: settings,
appDir: appDir,
bundle: _bundle({
'assets/keymaps/default.yaml': 'name: default\nbindings:\n - intent: dismiss\n keys: escape\n',
}),
);
await svc.load();
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
timeStamp: Duration.zero,
);
expect(svc.resolveEvent(down, HardwareKeyboard.instance), isA<DismissIntent>());
});
});
}
/// In-memory AssetBundle that returns whatever the constructor map says.
AssetBundle _bundle(Map<String, String> files) => _MapBundle(files);
class _MapBundle extends CachingAssetBundle {
_MapBundle(this._files);
final Map<String, String> _files;
@override
Future<ByteData> load(String key) async {
final s = _files[key];
if (s == null) throw Exception('asset not in fake bundle: $key');
return ByteData.view(Uint8List.fromList(s.codeUnits).buffer);
}
}