Raise the declared minimums in pubspec.yaml to what our deps already require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist 0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is the binding floor. Pin the exact build toolchain in .fvmrc (Flutter 3.44.1). Moving to the Dart 3.9 language level switches `dart format` to the new "tall" style and enables two new lints. This commit is the resulting mechanical churn, isolated from any behaviour change: - whole-tree `dart format` reformat (tall style) - `dart fix` for unnecessary_underscores + use_null_aware_elements No runtime behaviour change; `make test` green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
7.3 KiB
Dart
197 lines
7.3 KiB
Dart
/// Tests for the typed command-schema framework (T-120 / D-74):
|
|
/// argument coercion, per-constraint validation, the argv-shape
|
|
/// normaliser, and the dispatcher-level validation hook.
|
|
library;
|
|
|
|
import 'package:clide/src/daemon/dispatcher.dart';
|
|
import 'package:clide/src/ipc/command_schema.dart';
|
|
import 'package:clide/src/ipc/envelope.dart';
|
|
import 'package:clide/src/ipc/schema_v1.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('ArgSpec coercion + constraints', () {
|
|
SchemaResult run(ArgSpec spec, Object? value, {bool required = false}) {
|
|
final schema = CommandSchema(args: {'x': spec});
|
|
return schema.validate({'x': value});
|
|
}
|
|
|
|
test('string passes through; non-string rejected', () {
|
|
expect(run(const ArgSpec(), 'hi').isOk, isTrue);
|
|
final bad = run(const ArgSpec(), 42);
|
|
expect(bad.isOk, isFalse);
|
|
expect(bad.error, contains('expected a string'));
|
|
});
|
|
|
|
test('number coerces from a numeric string', () {
|
|
final r = run(const ArgSpec(type: ArgType.number), '3.5');
|
|
expect(r.isOk, isTrue);
|
|
expect(r.values!['x'], 3.5);
|
|
});
|
|
|
|
test('number rejects non-numeric text', () {
|
|
final r = run(const ArgSpec(type: ArgType.number), 'abc');
|
|
expect(r.isOk, isFalse);
|
|
expect(r.error, contains('expected a number'));
|
|
});
|
|
|
|
test('number honours min/max bounds', () {
|
|
expect(run(const ArgSpec(type: ArgType.number, min: 0, max: 1), 0.5).isOk, isTrue);
|
|
expect(run(const ArgSpec(type: ArgType.number, min: 0), -1).error, contains('>= 0'));
|
|
expect(run(const ArgSpec(type: ArgType.number, max: 1), 2).error, contains('<= 1'));
|
|
});
|
|
|
|
test('boolean coerces from "true"/"false" strings', () {
|
|
expect(run(const ArgSpec(type: ArgType.boolean), 'true').values!['x'], true);
|
|
expect(run(const ArgSpec(type: ArgType.boolean), 'false').values!['x'], false);
|
|
expect(run(const ArgSpec(type: ArgType.boolean), 'maybe').isOk, isFalse);
|
|
});
|
|
|
|
test('rejectLeadingDash blocks an argv-injection value', () {
|
|
final r = run(const ArgSpec(rejectLeadingDash: true), '--upload-pack=evil');
|
|
expect(r.isOk, isFalse);
|
|
expect(r.error, contains('must not start with "-"'));
|
|
});
|
|
|
|
test('allowed enforces a closed set', () {
|
|
final spec = ArgSpec(allowed: {'a', 'b'});
|
|
expect(run(spec, 'a').isOk, isTrue);
|
|
expect(run(spec, 'z').error, contains('one of'));
|
|
});
|
|
|
|
test('pattern must fully match', () {
|
|
final spec = ArgSpec(pattern: RegExp(r'^[0-9]+$'));
|
|
expect(run(spec, '123').isOk, isTrue);
|
|
expect(run(spec, '12a').error, contains('does not match'));
|
|
});
|
|
|
|
test('stringList coerces a scalar + caps element count', () {
|
|
final r = run(const ArgSpec(type: ArgType.stringList), 'solo');
|
|
expect(r.values!['x'], ['solo']);
|
|
final capped = run(const ArgSpec(type: ArgType.stringList, maxItems: 2), ['a', 'b', 'c']);
|
|
expect(capped.error, contains('cap is 2'));
|
|
});
|
|
|
|
test('stringList rejectLeadingDash inspects every element', () {
|
|
final r = run(const ArgSpec(type: ArgType.stringList, rejectLeadingDash: true), ['ok', '-bad']);
|
|
expect(r.isOk, isFalse);
|
|
expect(r.error, contains('-bad'));
|
|
});
|
|
|
|
test('stringList rejects a value that is neither list nor string', () {
|
|
final r = run(const ArgSpec(type: ArgType.stringList), 42);
|
|
expect(r.isOk, isFalse);
|
|
expect(r.error, contains('expected a list'));
|
|
});
|
|
});
|
|
|
|
group('required + unknown handling', () {
|
|
test('missing required arg fails', () {
|
|
final s = CommandSchema(args: {'name': const ArgSpec(required: true)});
|
|
final r = s.validate(const {});
|
|
expect(r.isOk, isFalse);
|
|
expect(r.error, contains('name is required'));
|
|
});
|
|
|
|
test('missing optional arg is fine', () {
|
|
final s = CommandSchema(args: {'name': const ArgSpec()});
|
|
expect(s.validate(const {}).isOk, isTrue);
|
|
});
|
|
|
|
test('undeclared keys are preserved untouched', () {
|
|
final s = CommandSchema(args: {'a': const ArgSpec()});
|
|
final r = s.validate(const {'a': 'x', 'extra': 99});
|
|
expect(r.isOk, isTrue);
|
|
expect(r.values!['extra'], 99);
|
|
});
|
|
});
|
|
|
|
group('normalize (argv shape → named args)', () {
|
|
const schema = CommandSchema(
|
|
positional: ['slot'],
|
|
args: {
|
|
'slot': ArgSpec(),
|
|
'to': ArgSpec(type: ArgType.number),
|
|
},
|
|
);
|
|
|
|
test('positional[i] maps to the i-th declared name', () {
|
|
final out = schema.normalize(const {
|
|
'positional': ['sidebar'],
|
|
'flags': {'to': '300'},
|
|
});
|
|
expect(out['slot'], 'sidebar');
|
|
expect(out['to'], '300');
|
|
});
|
|
|
|
test('passthrough is carried over', () {
|
|
final out = schema.normalize(const {
|
|
'positional': ['x'],
|
|
'passthrough': ['--', 'raw'],
|
|
});
|
|
expect(out['passthrough'], ['--', 'raw']);
|
|
});
|
|
|
|
test('direct (already-named) shape is returned unchanged', () {
|
|
final input = {'slot': 'context', 'to': 240};
|
|
expect(identical(schema.normalize(input), input), isTrue);
|
|
});
|
|
|
|
test('extra positionals beyond the declared names are dropped', () {
|
|
final out = schema.normalize(const {
|
|
'positional': ['a', 'b', 'c'],
|
|
});
|
|
expect(out['slot'], 'a');
|
|
expect(out.containsKey('b'), isFalse);
|
|
});
|
|
});
|
|
|
|
group('DaemonDispatcher schema gate', () {
|
|
test('validates + coerces before the handler runs', () async {
|
|
final d = DaemonDispatcher();
|
|
Object? seen;
|
|
d.register('demo.cmd', (req) async {
|
|
seen = req.args['n'];
|
|
return IpcResponse.ok(id: req.id, data: const {});
|
|
}, schema: const CommandSchema(args: {'n': ArgSpec(type: ArgType.number)}));
|
|
|
|
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.cmd', args: const {'n': '7'}));
|
|
expect(r.ok, isTrue);
|
|
expect(seen, 7); // coerced from the string "7"
|
|
});
|
|
|
|
test('rejects a violation with userError before the handler runs', () async {
|
|
final d = DaemonDispatcher();
|
|
var handlerRan = false;
|
|
d.register('demo.cmd', (req) async {
|
|
handlerRan = true;
|
|
return IpcResponse.ok(id: req.id, data: const {});
|
|
}, schema: const CommandSchema(args: {'ref': ArgSpec(rejectLeadingDash: true, required: true)}));
|
|
|
|
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.cmd', args: const {'ref': '-rf'}));
|
|
expect(r.ok, isFalse);
|
|
expect(r.error!.kind, IpcErrorKind.userError);
|
|
expect(handlerRan, isFalse);
|
|
});
|
|
|
|
test('a command with no schema dispatches unvalidated', () async {
|
|
final d = DaemonDispatcher();
|
|
d.register('demo.bare', (req) async {
|
|
return IpcResponse.ok(id: req.id, data: {'echo': req.args['anything']});
|
|
});
|
|
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.bare', args: const {'anything': '-not-checked'}));
|
|
expect(r.ok, isTrue);
|
|
expect(r.data['echo'], '-not-checked');
|
|
});
|
|
|
|
test('re-registering a command without a schema clears its old schema', () async {
|
|
final d = DaemonDispatcher();
|
|
handler(IpcRequest req) async => IpcResponse.ok(id: req.id, data: const {});
|
|
d.register('demo.cmd', handler, schema: const CommandSchema(args: {'r': ArgSpec(required: true)}));
|
|
d.register('demo.cmd', handler); // no schema this time
|
|
final r = await d.dispatch(IpcRequest(id: '1', cmd: 'demo.cmd', args: const {}));
|
|
expect(r.ok, isTrue); // required check no longer applies
|
|
});
|
|
});
|
|
}
|