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>
118 lines
4.0 KiB
Dart
118 lines
4.0 KiB
Dart
/// 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)');
|
|
});
|
|
});
|
|
}
|