test sweep: cover daemon dispatcher + git_commands (T-91)

Two test additions toward finishing src/daemon/:

- test/daemon/dispatcher_test.dart (6 tests): the entire
  DaemonDispatcher surface — ping + version default handlers,
  unknown-command not-found error, register routing, isEmpty
  before/after registration, clear preserving ping + version.
- test/daemon/git_commands_test.dart extended (14 new tests): the
  git.* commands the existing suite didn't reach — git.diff with
  paths, git.stage-hunk + git.unstage-hunk (happy + missing-patch
  + bad-patch GitException), git.branches, git.checkout (happy +
  missing + unknown), git.log with count, git.push + git.pull both
  with and without a local bare remote, git.stage accepting a
  String single-path arg via _pathList.

Coverage: src/daemon/dispatcher.dart 8/22 -> 22/22 (100%);
src/daemon/git_commands.dart 73/146 -> 122/146 (84%). The
remaining 24 lines in git_commands are mid-call GitException
catch branches that need the git client to fail after the
dispatcher accepted the request.

Total coverage 77.05% -> 77.92%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 07:09:33 +02:00
co-authored by Claude Opus 4.7
parent 035491f7db
commit 8eaf7446a1
2 changed files with 176 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
/// Unit tests for the daemon's command dispatcher.
library;
import 'package:clide/clide.dart';
import 'package:clide/src/daemon/dispatcher.dart';
import 'package:test/test.dart';
IpcRequest _req(String cmd, {String id = '1', Map<String, Object?> args = const {}}) {
return IpcRequest(id: id, cmd: cmd, args: args);
}
void main() {
group('DaemonDispatcher', () {
test('ping is registered by default and returns pong + version', () async {
final d = DaemonDispatcher();
final r = await d.dispatch(_req('ping'));
expect(r.ok, isTrue);
expect(r.data['pong'], isTrue);
expect(r.data['version'], isNotNull);
expect(r.data['ts'], isA<String>());
});
test('version returns the bundled version string', () async {
final d = DaemonDispatcher();
final r = await d.dispatch(_req('version'));
expect(r.ok, isTrue);
expect(r.data['version'], clideVersion);
});
test('dispatching an unknown command produces a not-found error', () async {
final d = DaemonDispatcher();
final r = await d.dispatch(_req('nonsense'));
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.notFound);
expect(r.error?.message, contains('unknown command'));
});
test('register routes a new handler', () async {
final d = DaemonDispatcher();
d.register('echo', (req) async {
return IpcResponse.ok(id: req.id, data: {'echo': req.args['text']});
});
final r = await d.dispatch(_req('echo', args: {'text': 'hi'}));
expect(r.ok, isTrue);
expect(r.data['echo'], 'hi');
});
test('isEmpty is true for a fresh dispatcher (ping + version only)', () {
final d = DaemonDispatcher();
expect(d.isEmpty, isTrue);
d.register('something', (req) async => IpcResponse.ok(id: req.id, data: const {}));
expect(d.isEmpty, isFalse);
});
test('clear removes user handlers but keeps ping + version', () async {
final d = DaemonDispatcher();
d.register('extra', (req) async => IpcResponse.ok(id: req.id, data: const {}));
expect(d.isEmpty, isFalse);
d.clear();
expect(d.isEmpty, isTrue);
// ping still works
final r = await d.dispatch(_req('ping'));
expect(r.ok, isTrue);
// 'extra' is gone
final r2 = await d.dispatch(_req('extra'));
expect(r2.ok, isFalse);
});
});
}
+107
View File
@@ -188,4 +188,111 @@ void main() {
content = await File('${sandbox.path}/file.txt').readAsString();
expect(content, 'stash-me');
});
test('git.diff with explicit paths narrows the result', () async {
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
await File('${sandbox.path}/other.txt').writeAsString('o');
final r = await call('git.diff', {
'paths': ['file.txt']
});
expect(r.ok, isTrue);
final diffs = r.data['diffs'] as List;
expect(diffs, hasLength(1));
});
test('git.stage-hunk requires a non-empty patch', () async {
final missing = await call('git.stage-hunk');
expect(missing.ok, isFalse);
expect(missing.error?.kind, IpcErrorKind.userError);
final empty = await call('git.stage-hunk', {'patch': ''});
expect(empty.ok, isFalse);
});
test('git.unstage-hunk requires a non-empty patch', () async {
final missing = await call('git.unstage-hunk');
expect(missing.ok, isFalse);
expect(missing.error?.kind, IpcErrorKind.userError);
});
test('git.stage-hunk + git.unstage-hunk round-trip a real patch', () async {
await File('${sandbox.path}/file.txt').writeAsString('hello\nworld\n');
final p = await Process.run('git', ['diff', '-U0'], workingDirectory: sandbox.path);
final patch = p.stdout as String;
final staged = await call('git.stage-hunk', {'patch': patch});
expect(staged.ok, isTrue);
final unstaged = await call('git.unstage-hunk', {'patch': patch});
expect(unstaged.ok, isTrue);
});
test('git.stage-hunk surfaces GitException as a tool error', () async {
final r = await call('git.stage-hunk', {'patch': 'not a valid patch\n'});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.branches lists the local branches', () async {
await Process.run('git', ['branch', 'feature/a'], workingDirectory: sandbox.path);
final r = await call('git.branches');
expect(r.ok, isTrue);
final branches = r.data['branches'] as List;
expect(branches.map((b) => (b as Map)['name']), containsAll(['feature/a']));
});
test('git.checkout requires a branch name', () async {
final missing = await call('git.checkout');
expect(missing.ok, isFalse);
expect(missing.error?.kind, IpcErrorKind.userError);
final empty = await call('git.checkout', {'branch': ''});
expect(empty.ok, isFalse);
});
test('git.checkout switches branches', () async {
await Process.run('git', ['branch', 'next'], workingDirectory: sandbox.path);
final r = await call('git.checkout', {'branch': 'next'});
expect(r.ok, isTrue);
expect(r.data['branch'], 'next');
});
test('git.checkout to an unknown branch surfaces a tool error', () async {
final r = await call('git.checkout', {'branch': 'no-such-branch'});
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.log accepts an explicit count', () async {
final r = await call('git.log', {'count': 5});
expect(r.ok, isTrue);
final entries = r.data['entries'] as List;
expect(entries, isNotEmpty);
});
test('git.push to no remote surfaces a tool error', () async {
final r = await call('git.push');
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.pull with no remote surfaces a tool error', () async {
final r = await call('git.pull');
expect(r.ok, isFalse);
expect(r.error?.kind, IpcErrorKind.toolError);
});
test('git.push + git.pull against a local bare remote return output', () async {
final remote = await Directory.systemTemp.createTemp('clide-git-cmd-remote-');
addTearDown(() => remote.deleteSync(recursive: true));
await Process.run('git', ['init', '--bare'], workingDirectory: remote.path);
await Process.run('git', ['remote', 'add', 'origin', remote.path], workingDirectory: sandbox.path);
final pushed = await call('git.push', {'remote': 'origin', 'branch': 'HEAD', 'setUpstream': true});
expect(pushed.ok, isTrue);
final pulled = await call('git.pull');
expect(pulled.ok, isTrue);
});
test('git.stage accepts a string single-path arg', () async {
await File('${sandbox.path}/new.txt').writeAsString('x');
// _pathList accepts a String, wrapping it as a singleton.
final r = await call('git.stage', {'paths': 'new.txt'});
expect(r.ok, isTrue);
});
}