keystroke mapper layer — intents, presets, when-clauses (T-117)

Build the upstream of every keyboard-driven feature: widgets bind
to typed Intents, the keymap resolves chord+context to an Intent,
and Flutter's Actions dispatches. The widget never touches a key.

Layers (low → high precedence):
  1. preset YAML in assets/keymaps/<preset>.yaml
  2. extension-registered command bindings (via
     KeymapService.registerCommandBinding from ExtensionManager)
  3. user file at <appDir>/keybindings.yaml
  4. settings JSON overlay at app.keymap.overrides

The when-clause grammar is a tiny recursive-descent parser over
boolean expressions on a named context bag — VS-Code style
`palette.open && !textInputFocused`. Producing services publish
scope flags via setScopeFlag.

Keys reference LogicalKeyboardKey.keyId (stable across keyboard
layouts), not the locale-aware keyLabel the consultant flagged.

Ships:
  - lib/kernel/src/keymap/{key_chord, when_clause, intents, keymap,
    keymap_service}.dart
  - assets/keymaps/default.yaml (the baseline preset)
  - 90+ unit tests covering parser precedence, layering precedence,
    scope evaluation, register/unregister, settings overlay,
    malformed-input tolerance
  - app.dart root handler routes through KeymapService → Actions
  - ExtensionManager mirrors every legacy defaultBinding into the
    keymap as a contribution layer

KeybindingResolver kept temporarily as a back-compat shim for
callers we haven't migrated yet; safe to delete once the last
caller goes through Actions.

Closes T-110 (consultant: scoped Shortcuts/Actions; off keyLabel).
Annotates T-23 with what's left for T-100. Unblocks T-64 / T-65 /
T-66 (preset data tickets).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:40:02 +02:00
co-authored by Claude Opus 4.7
parent a8729db893
commit 798ba524f1
18 changed files with 1944 additions and 47 deletions
+154
View File
@@ -0,0 +1,154 @@
/// Unit tests for KeyChord parsing, equality, canonicalisation, and
/// fromKeyEvent.
library;
import 'package:clide/kernel/src/keymap/key_chord.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('KeyChord.parse', () {
test('single key without modifiers', () {
final c = KeyChord.parse('enter');
expect(c.modifiers, isEmpty);
expect(c.key, LogicalKeyboardKey.enter);
expect(c.canonical, 'enter');
});
test('modifier order is canonicalised', () {
final a = KeyChord.parse('shift+ctrl+p');
final b = KeyChord.parse('ctrl+shift+p');
expect(a, b);
expect(a.canonical, 'ctrl+shift+p');
});
test('cmd/meta/command/super/win all alias the meta modifier', () {
for (final spec in ['cmd+a', 'meta+a', 'command+a', 'super+a', 'win+a']) {
expect(KeyChord.parse(spec).modifiers, [KeyModifier.meta], reason: spec);
}
});
test('punctuation keys are recognised by name or character', () {
expect(KeyChord.parse('ctrl+slash').key, LogicalKeyboardKey.slash);
expect(KeyChord.parse('ctrl+/').key, LogicalKeyboardKey.slash);
expect(KeyChord.parse('ctrl+equal').key, LogicalKeyboardKey.equal);
expect(KeyChord.parse('ctrl+=').key, LogicalKeyboardKey.equal);
});
test('parse is case-insensitive on modifiers + key name', () {
final c = KeyChord.parse('CTRL+SHIFT+P');
expect(c.modifiers, [KeyModifier.ctrl, KeyModifier.shift]);
expect(c.key, LogicalKeyboardKey.keyP);
});
});
group('KeyChord.parse — errors', () {
test('empty string throws', () {
expect(() => KeyChord.parse(''), throwsFormatException);
});
test('unknown modifier throws', () {
expect(() => KeyChord.parse('hyper+a'), throwsFormatException);
});
test('unknown key throws', () {
expect(() => KeyChord.parse('ctrl+definitely-not-a-key'), throwsFormatException);
});
test('trailing + (missing key) throws', () {
expect(() => KeyChord.parse('ctrl+'), throwsFormatException);
});
});
group('KeyChord display + canonical', () {
test('display capitalises modifiers + key, joined with +', () {
expect(KeyChord.parse('ctrl+shift+p').display, 'Ctrl+Shift+P');
expect(KeyChord.parse('enter').display, 'ENTER');
});
test('each modifier renders its own display string', () {
expect(KeyChord.parse('ctrl+a').display, 'Ctrl+A');
expect(KeyChord.parse('alt+a').display, 'Alt+A');
expect(KeyChord.parse('shift+a').display, 'Shift+A');
expect(KeyChord.parse('meta+a').display, 'Cmd+A');
});
test('toString embeds the canonical form', () {
expect(KeyChord.parse('ctrl+shift+p').toString(), 'KeyChord(ctrl+shift+p)');
});
});
group('KeyChord equality + hashing', () {
test('equal chords have equal hash codes', () {
final a = KeyChord.parse('ctrl+shift+p');
final b = KeyChord.parse('shift+ctrl+p');
expect(a, b);
expect(a.hashCode, b.hashCode);
});
test('different keys are not equal', () {
expect(KeyChord.parse('ctrl+a'), isNot(KeyChord.parse('ctrl+b')));
});
test('different modifier sets are not equal', () {
expect(KeyChord.parse('ctrl+a'), isNot(KeyChord.parse('alt+a')));
});
});
group('KeyChord.fromKeyEvent', () {
final kb = HardwareKeyboard.instance;
tearDown(() => kb.clearState());
test('returns null for non-KeyDown / non-Repeat events', () {
final up = KeyUpEvent(
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
timeStamp: Duration.zero,
);
expect(KeyChord.fromKeyEvent(up, kb), isNull);
});
test('returns null for a bare modifier press', () {
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.controlLeft,
logicalKey: LogicalKeyboardKey.controlLeft,
timeStamp: Duration.zero,
);
expect(KeyChord.fromKeyEvent(down, kb), isNull);
});
test('maps a plain key down to a modifier-free chord', () {
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
timeStamp: Duration.zero,
);
final chord = KeyChord.fromKeyEvent(down, kb)!;
expect(chord.modifiers, isEmpty);
expect(chord.key, LogicalKeyboardKey.keyA);
});
test('records every held modifier in the resulting chord', () {
// Press all four modifiers, then a non-modifier key.
for (final m in [
(PhysicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlLeft),
(PhysicalKeyboardKey.altLeft, LogicalKeyboardKey.altLeft),
(PhysicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftLeft),
(PhysicalKeyboardKey.metaLeft, LogicalKeyboardKey.metaLeft),
]) {
kb.handleKeyEvent(KeyDownEvent(physicalKey: m.$1, logicalKey: m.$2, timeStamp: Duration.zero));
}
final down = KeyDownEvent(
physicalKey: PhysicalKeyboardKey.keyP,
logicalKey: LogicalKeyboardKey.keyP,
timeStamp: Duration.zero,
);
final chord = KeyChord.fromKeyEvent(down, kb)!;
expect(chord.key, LogicalKeyboardKey.keyP);
expect(chord.modifiers.toSet(), {KeyModifier.ctrl, KeyModifier.alt, KeyModifier.shift, KeyModifier.meta});
expect(chord.canonical, 'ctrl+alt+shift+meta+p');
});
});
}
@@ -0,0 +1,287 @@
/// 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_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');
// `id` carries the command suffix for round-trip identification.
expect(invoke.id, 'command: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);
}
}
+188
View File
@@ -0,0 +1,188 @@
/// Unit tests for Keymap layering + resolution + KeymapLayer YAML parsing.
library;
import 'package:clide/kernel/src/keymap/intents.dart';
import 'package:clide/kernel/src/keymap/key_chord.dart';
import 'package:clide/kernel/src/keymap/keymap.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('KeymapLayer.fromYaml', () {
test('parses a single binding with one chord', () {
const src = '''
name: test
bindings:
- intent: activate
keys: enter
''';
final layer = KeymapLayer.fromYaml(src);
expect(layer.name, 'test');
expect(layer.bindings, hasLength(1));
expect(layer.bindings.single.chord, KeyChord.parse('enter'));
expect(layer.bindings.single.intent, isA<ActivateIntent>());
expect(layer.bindings.single.when, isNull);
});
test('expands `keys:` list into one binding per chord', () {
const src = '''
name: t
bindings:
- intent: activate
keys: [enter, space]
''';
final layer = KeymapLayer.fromYaml(src);
expect(layer.bindings, hasLength(2));
expect(layer.bindings[0].chord, KeyChord.parse('enter'));
expect(layer.bindings[1].chord, KeyChord.parse('space'));
// Same intent instance reused — fine because intents are const.
expect(layer.bindings[0].intent, isA<ActivateIntent>());
});
test('stores when-clause parsed into an evaluable WhenExpr', () {
const src = '''
name: t
bindings:
- intent: palette.selectNext
keys: down
when: palette.open && !textInputFocused
''';
final layer = KeymapLayer.fromYaml(src);
final w = layer.bindings.single.when!;
expect(w.evaluate({'palette.open': true, 'textInputFocused': false}), isTrue);
expect(w.evaluate({'palette.open': true, 'textInputFocused': true}), isFalse);
expect(w.evaluate({'palette.open': false, 'textInputFocused': false}), isFalse);
});
test('command: prefix resolves to InvokeCommandIntent', () {
const src = '''
name: t
bindings:
- intent: command:git.commit
keys: ctrl+shift+g
''';
final layer = KeymapLayer.fromYaml(src);
final intent = layer.bindings.single.intent;
expect(intent, isA<InvokeCommandIntent>());
expect((intent as InvokeCommandIntent).commandId, 'git.commit');
});
test('nameOverride wins over `name:`', () {
const src = 'name: ignored\nbindings: []\n';
final layer = KeymapLayer.fromYaml(src, nameOverride: 'user-file');
expect(layer.name, 'user-file');
});
test('rejects unknown intent id', () {
const src = 'name: t\nbindings:\n - intent: definitely.not.real\n keys: enter\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
test('rejects missing keys', () {
const src = 'name: t\nbindings:\n - intent: activate\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
test('rejects non-map top level', () {
expect(() => KeymapLayer.fromYaml('- one\n- two\n'), throwsFormatException);
});
test('rejects missing bindings list', () {
expect(() => KeymapLayer.fromYaml('name: t\n'), throwsFormatException);
});
test('rejects non-string entries in keys list', () {
const src = 'name: t\nbindings:\n - intent: activate\n keys: [42]\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
test('rejects unparseable keys value (neither string nor list)', () {
const src = 'name: t\nbindings:\n - intent: activate\n keys: {wrong: shape}\n';
expect(() => KeymapLayer.fromYaml(src), throwsFormatException);
});
});
group('Keymap + KeymapLayer toString', () {
test('layer toString includes name + binding count', () {
final layer = KeymapLayer.fromYaml('name: t\nbindings:\n - intent: dismiss\n keys: escape\n');
expect(layer.toString(), 'KeymapLayer(t, 1 binding)');
});
test('keymap toString lists layers low-to-high', () {
final a = KeymapLayer.fromYaml('name: a\nbindings: []\n');
final b = KeymapLayer.fromYaml('name: b\nbindings: []\n');
expect(Keymap([a, b]).toString(), 'Keymap(a < b)');
});
});
group('Keymap.resolve — single layer', () {
final layer = KeymapLayer.fromYaml('''
name: t
bindings:
- intent: activate
keys: enter
- intent: palette.selectNext
keys: down
when: palette.open
''');
test('matches an unconditional binding', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('enter'), const {}), isA<ActivateIntent>());
});
test('matches a when-gated binding when the flag is true', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('down'), {'palette.open': true}), isA<PaletteSelectNextIntent>());
});
test('skips a when-gated binding when the flag is false', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('down'), const {}), isNull);
});
test('returns null when the chord has no binding', () {
final km = Keymap([layer]);
expect(km.resolve(KeyChord.parse('ctrl+x'), const {}), isNull);
});
});
group('Keymap.resolve — layering precedence', () {
test('later layer replaces earlier binding for the same chord', () {
final preset = KeymapLayer.fromYaml('''
name: preset
bindings:
- intent: palette.open
keys: ctrl+shift+p
''');
final user = KeymapLayer.fromYaml('''
name: user
bindings:
- intent: activate
keys: ctrl+shift+p
''');
final km = Keymap([preset, user]);
// User layer wins — Activate, not PaletteOpen.
expect(km.resolve(KeyChord.parse('ctrl+shift+p'), const {}), isA<ActivateIntent>());
});
test('preset binding survives when no later layer overrides it', () {
final preset = KeymapLayer.fromYaml('''
name: preset
bindings:
- intent: dismiss
keys: escape
''');
final user = KeymapLayer.fromYaml('''
name: user
bindings:
- intent: activate
keys: enter
''');
final km = Keymap([preset, user]);
expect(km.resolve(KeyChord.parse('escape'), const {}), isA<DismissIntent>());
expect(km.resolve(KeyChord.parse('enter'), const {}), isA<ActivateIntent>());
});
});
}
@@ -0,0 +1,117 @@
/// Unit tests for the when-clause parser + evaluator.
library;
import 'package:clide/kernel/src/keymap/when_clause.dart';
import 'package:test/test.dart';
void main() {
group('WhenExpr.parse — grammar', () {
test('single identifier', () {
final e = WhenExpr.parse('foo');
expect(e, isA<WhenIdent>());
expect(e.evaluate({'foo': true}), isTrue);
expect(e.evaluate({'foo': false}), isFalse);
expect(e.evaluate(const {}), isFalse, reason: 'missing identifier evaluates to false');
});
test('negation', () {
final e = WhenExpr.parse('!foo');
expect(e.evaluate({'foo': true}), isFalse);
expect(e.evaluate({'foo': false}), isTrue);
expect(e.evaluate(const {}), isTrue, reason: '!missing → true');
});
test('double negation', () {
final e = WhenExpr.parse('!!foo');
expect(e.evaluate({'foo': true}), isTrue);
expect(e.evaluate({'foo': false}), isFalse);
});
test('conjunction is left-associative', () {
final e = WhenExpr.parse('a && b && c');
expect(e.evaluate({'a': true, 'b': true, 'c': true}), isTrue);
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isFalse);
});
test('disjunction is left-associative', () {
final e = WhenExpr.parse('a || b || c');
expect(e.evaluate({'a': false, 'b': false, 'c': true}), isTrue);
expect(e.evaluate({'a': false, 'b': false, 'c': false}), isFalse);
});
test('and binds tighter than or', () {
// a || b && c == a || (b && c)
final e = WhenExpr.parse('a || b && c');
expect(e.evaluate({'a': false, 'b': true, 'c': false}), isFalse);
expect(e.evaluate({'a': false, 'b': true, 'c': true}), isTrue);
expect(e.evaluate({'a': true, 'b': false, 'c': false}), isTrue);
});
test('parens override precedence', () {
// (a || b) && c
final e = WhenExpr.parse('(a || b) && c');
expect(e.evaluate({'a': true, 'b': false, 'c': false}), isFalse);
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isTrue);
});
test('not binds tighter than and/or', () {
// !a && b → (!a) && b
final e = WhenExpr.parse('!a && b');
expect(e.evaluate({'a': false, 'b': true}), isTrue);
expect(e.evaluate({'a': true, 'b': true}), isFalse);
});
test('identifiers may contain dots, hyphens, underscores', () {
final e = WhenExpr.parse('palette.is-open && _editor_focused');
expect(e.evaluate({'palette.is-open': true, '_editor_focused': true}), isTrue);
});
test('whitespace is tolerated', () {
final e = WhenExpr.parse(' a && ( b || !c ) ');
expect(e.evaluate({'a': true, 'b': true, 'c': true}), isTrue);
expect(e.evaluate({'a': true, 'b': false, 'c': true}), isFalse);
});
});
group('WhenExpr.parse — errors', () {
test('empty input throws', () {
expect(() => WhenExpr.parse(''), throwsFormatException);
});
test('unbalanced paren throws', () {
expect(() => WhenExpr.parse('(a && b'), throwsFormatException);
});
test('trailing junk throws', () {
expect(() => WhenExpr.parse('a && b foo'), throwsFormatException);
});
test('missing operand after operator throws', () {
expect(() => WhenExpr.parse('a &&'), throwsFormatException);
});
test('bare ! throws', () {
expect(() => WhenExpr.parse('!'), throwsFormatException);
});
});
group('WhenExpr.tryParse', () {
test('null and empty return null', () {
expect(WhenExpr.tryParse(null), isNull);
expect(WhenExpr.tryParse(' '), isNull);
});
test('non-empty delegates to parse', () {
expect(WhenExpr.tryParse('foo'), isA<WhenIdent>());
});
});
group('WhenExpr.toString', () {
test('round-trips each node shape', () {
expect(WhenExpr.parse('foo').toString(), 'foo');
expect(WhenExpr.parse('!foo').toString(), '!foo');
expect(WhenExpr.parse('a && b').toString(), '(a && b)');
expect(WhenExpr.parse('a || b').toString(), '(a || b)');
});
});
}