Files
clide/test/kernel/src/keymap/when_clause_test.dart
T
jpmschweitzerandClaude Opus 4.7 798ba524f1 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>
2026-05-17 21:40:02 +02:00

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)');
});
});
}