import 'package:dew_core/dew_core.dart'; import 'package:dew_kanban/dew_kanban.dart'; import 'package:file/memory.dart'; import 'package:test/test.dart'; const _testConfig = ''' dew: mcp: host: localhost port: 9090 kanban: prefix: T ticket_types: - id: task name: Task columns: - id: todo name: To Do color: blue '''; MemoryFileSystem _makeFs() { final fs = MemoryFileSystem(); fs.directory('/.project/kanban').createSync(recursive: true); fs.file('/.project/dew.yaml').writeAsStringSync(_testConfig); return fs; } void main() { group('KanbanCommand', () { test('has correct name and description', () { final cmd = KanbanCommand(); expect(cmd.name, 'kanban'); expect(cmd.description, isNotEmpty); }); test('has expected subcommands', () { final cmd = KanbanCommand(); expect( cmd.subcommands.keys, containsAll([ 'create', 'list', 'board', 'get', 'update', 'delete', 'archive', 'unarchive', 'move', 'search', 'comment', 'config', 'stats', 'link', 'unlink', ]), ); }); test('registerCommands adds kanban command to registry', () { final registry = CommandRegistry(); registerCommands(registry); expect(registry.commands.map((c) => c.name), contains('kanban')); }); }); group('CommandRegistry.mcpTools via kanban', () { test('exposes twelve tools with unique names', () { final registry = CommandRegistry(); registerCommands(registry); final tools = registry.mcpTools; expect(tools, hasLength(15)); final names = tools.map((t) => t.name).toSet(); expect(names, { 'kanban_create_ticket', 'kanban_list_tickets', 'kanban_board', 'kanban_get_ticket', 'kanban_update_ticket', 'kanban_delete_ticket', 'kanban_archive_ticket', 'kanban_unarchive_ticket', 'kanban_move_ticket', 'kanban_search_tickets', 'kanban_add_comment', 'kanban_get_config', 'kanban_stats', 'kanban_link_tickets', 'kanban_unlink_tickets', }); }); test('all tools have non-empty descriptions and object inputSchema', () { final registry = CommandRegistry(); registerCommands(registry); for (final tool in registry.mcpTools) { expect( tool.description, isNotEmpty, reason: '${tool.name} description', ); expect( tool.inputSchema['type'], 'object', reason: '${tool.name} schema type', ); } }); test('schema derived from argParser — create tool has required fields', () { final registry = CommandRegistry(); registerCommands(registry); final create = registry.mcpTools.firstWhere( (t) => t.name == 'kanban_create_ticket', ); final required = create.inputSchema['required'] as List; expect(required, containsAll(['title', 'type'])); }); test('create and list tools have working handlers', () async { final fs = _makeFs(); final registry = CommandRegistry(); registerCommands(registry, fs: fs); final tools = {for (final t in registry.mcpTools) t.name: t}; final result = await tools['kanban_create_ticket']!.handler({ 'title': 'Hello', 'type': 'task', }); expect(result, contains('T-0001')); final listResult = await tools['kanban_list_tickets']!.handler({}); expect(listResult, contains('T-0001')); final searchResult = await tools['kanban_search_tickets']!.handler({ 'query': 'Hello', }); expect(searchResult, contains('T-0001')); await tools['kanban_add_comment']!.handler({ 'id': 'T-0001', 'comment': 'Nice ticket.', }); final getResult = await tools['kanban_get_ticket']!.handler({ 'id': 'T-0001', }); expect(getResult, contains('Nice ticket.')); final configResult = await tools['kanban_get_config']!.handler({}); expect(configResult, contains('todo')); expect(configResult, contains('task')); final statsResult = await tools['kanban_stats']!.handler({}); expect(statsResult, contains('Total: 1')); expect(statsResult, contains('todo: 1')); expect(statsResult, contains('task: 1')); }); }); group('MoveCommand transition validation', () { test('move respects allowed_transitions when configured', () async { final fs = MemoryFileSystem(); fs.directory('/.project/kanban').createSync(recursive: true); fs.file('/.project/dew.yaml').writeAsStringSync(''' dew: mcp: host: localhost port: 9090 kanban: prefix: T ticket_types: - id: task name: Task columns: - id: backlog name: Backlog color: grey allowed_transitions: - doing - id: doing name: Doing color: blue allowed_transitions: - done - backlog - id: done name: Done color: green '''); final registry = CommandRegistry(); registerCommands(registry, fs: fs); final tools = {for (final t in registry.mcpTools) t.name: t}; await tools['kanban_create_ticket']!.handler({ 'title': 'Flow test', 'type': 'task', }); // Allowed: backlog → doing await expectLater( tools['kanban_move_ticket']!.handler({ 'id': 'T-0001', 'column': 'doing', }), completes, ); // Reset to backlog first. await tools['kanban_move_ticket']!.handler({ 'id': 'T-0001', 'column': 'backlog', }); // backlog → done should throw (not in allowed_transitions). await expectLater( tools['kanban_move_ticket']!.handler({ 'id': 'T-0001', 'column': 'done', }), throwsA(isA()), ); // Unconstrained column (done) — any target is valid. await tools['kanban_move_ticket']!.handler({ 'id': 'T-0001', 'column': 'doing', }); await tools['kanban_move_ticket']!.handler({ 'id': 'T-0001', 'column': 'done', }); // done → backlog: done has no constraints, so it's allowed. final result = await tools['kanban_move_ticket']!.handler({ 'id': 'T-0001', 'column': 'backlog', }); expect(result, contains('T-0001')); }); }); group('Ticket', () { test('roundtrip serialisation', () { final t = Ticket( id: 'TEST-0001', title: 'Do a thing', type: 'task', column: 'todo', created: DateTime.utc(2026, 1, 1, 12), body: 'Some body.', comments: ['First comment.', 'Second comment.'], ); final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.id, t.id); expect(parsed.title, t.title); expect(parsed.type, t.type); expect(parsed.column, t.column); expect(parsed.created.toUtc(), t.created.toUtc()); expect(parsed.body, t.body); expect(parsed.comments, t.comments); }); test('empty body and no comments', () { final t = Ticket( id: 'TEST-0002', title: 'Empty', type: 'task', column: 'todo', created: DateTime.utc(2026, 1, 2), body: '', comments: const [], ); final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.body, ''); expect(parsed.comments, isEmpty); }); test('links roundtrip serialisation', () { final t = Ticket( id: 'TEST-0003', title: 'Linked', type: 'task', column: 'todo', created: DateTime.utc(2026, 1, 3), body: '', comments: const [], links: [ TicketLink(targetId: 'TEST-0001', type: 'blocks'), TicketLink(targetId: 'TEST-0002', type: 'relates_to'), ], ); final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.links, hasLength(2)); expect(parsed.links[0].targetId, 'TEST-0001'); expect(parsed.links[0].type, 'blocks'); expect(parsed.links[1].targetId, 'TEST-0002'); expect(parsed.links[1].type, 'relates_to'); }); test('no links field when links is empty', () { final t = Ticket( id: 'TEST-0004', title: 'No links', type: 'task', column: 'todo', created: DateTime.utc(2026, 1, 4), body: '', comments: const [], ); expect(t.toFileContent(), isNot(contains('links:'))); }); test('milestones and labels roundtrip serialisation', () { final t = Ticket( id: 'TEST-0005', title: 'Tagged', type: 'task', column: 'todo', created: DateTime.utc(2026, 1, 5), body: '', comments: const [], milestones: ['v1.0', 'beta'], labels: ['good first issue', 'backend'], ); final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.milestones, ['v1.0', 'beta']); expect(parsed.labels, ['good first issue', 'backend']); }); test('no milestones/labels fields when empty', () { final t = Ticket( id: 'TEST-0006', title: 'Plain', type: 'task', column: 'todo', created: DateTime.utc(2026, 1, 6), body: '', comments: const [], ); final content = t.toFileContent(); expect(content, isNot(contains('milestones:'))); expect(content, isNot(contains('labels:'))); }); }); group('TicketStore', () { TicketStore makeStore(MemoryFileSystem fs) => TicketStore(kanbanDir: '/kanban', prefix: 'TEST', fs: fs); test('create assigns incrementing IDs', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); final t1 = await store.create( title: 'First', type: 'task', column: 'todo', ); final t2 = await store.create( title: 'Second', type: 'bug', column: 'todo', ); expect(t1.id, 'TEST-0001'); expect(t2.id, 'TEST-0002'); }); test('findById returns null for missing ticket', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); expect(await store.findById('TEST-0099'), isNull); }); test('create and list milestones/labels persist via store', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create( title: 'Tagged', type: 'task', column: 'todo', milestones: ['v1.0'], labels: ['enhancement'], ); await store.create(title: 'Plain', type: 'task', column: 'todo'); final all = await store.list(); expect(all[0].milestones, ['v1.0']); expect(all[0].labels, ['enhancement']); expect(all[1].milestones, isEmpty); expect(all[1].labels, isEmpty); }); test('update patches milestones and labels', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create( title: 'Tagged', type: 'task', column: 'todo', milestones: ['v1.0'], labels: ['old'], ); final updated = await store.update('TEST-0001', labels: ['new']); expect(updated.milestones, ['v1.0']); expect(updated.labels, ['new']); }); test('list returns sorted tickets', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); final all = await store.list(); expect(all.map((t) => t.id), ['TEST-0001', 'TEST-0002']); }); test('list excludes archive by default, includes with flag', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'Active', type: 'task', column: 'todo'); // Manually move to archive dir to simulate archived state. fs.directory('/kanban/archive').createSync(recursive: true); final src = fs.file('/kanban/todo/TEST-0001.md'); await src.rename('/kanban/archive/TEST-0001.md'); expect(await store.list(), isEmpty); final withArchive = await store.list(includeArchived: true); expect(withArchive, hasLength(1)); expect(withArchive.first.column, 'archive'); }); test('update patches specified fields', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'Old', type: 'task', column: 'todo'); final updated = await store.update('TEST-0001', title: 'New'); expect(updated.title, 'New'); expect(updated.type, 'task'); }); test('update throws for missing ticket', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); expect( () => store.update('TEST-0099', title: 'X'), throwsA(isA()), ); }); test('delete removes ticket', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'Bye', type: 'task', column: 'todo'); await store.delete('TEST-0001'); expect(await store.findById('TEST-0001'), isNull); }); test('delete throws for missing ticket', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); expect(() => store.delete('TEST-0099'), throwsA(isA())); }); test( 'linkTickets adds typed link bidirectionally and is idempotent', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); final a = await store.findById('TEST-0001'); expect(a!.links, hasLength(1)); expect(a.links.first.targetId, 'TEST-0002'); expect(a.links.first.type, 'blocks'); // Inverse written on target. final b = await store.findById('TEST-0002'); expect(b!.links, hasLength(1)); expect(b.links.first.targetId, 'TEST-0001'); expect(b.links.first.type, 'is_blocked_by'); // Idempotent — calling again doesn't add duplicates. await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); final a2 = await store.findById('TEST-0001'); expect(a2!.links, hasLength(1)); }, ); test('linkTickets relates_to is symmetric', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'relates_to'); final a = await store.findById('TEST-0001'); final b = await store.findById('TEST-0002'); expect(a!.links.first.type, 'relates_to'); expect(b!.links.first.type, 'relates_to'); }); test('linkTickets parent_of / child_of inverse pair', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'Epic', type: 'task', column: 'todo'); await store.create(title: 'Story', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'parent_of'); final parent = await store.findById('TEST-0001'); final child = await store.findById('TEST-0002'); expect(parent!.links.first.type, 'parent_of'); expect(child!.links.first.type, 'child_of'); }); test('linkTickets throws for self-link via command', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); // Self-link guard is in the command layer, not the store. }); test('unlinkTickets removes link on both sides', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'todo'); await store.linkTickets('TEST-0001', 'TEST-0002', 'blocks'); await store.unlinkTickets('TEST-0001', 'TEST-0002'); final a = await store.findById('TEST-0001'); final b = await store.findById('TEST-0002'); expect(a!.links, isEmpty); expect(b!.links, isEmpty); }); test('stats returns correct counts', () async { final fs = MemoryFileSystem(); final store = makeStore(fs); await store.create(title: 'A', type: 'task', column: 'todo'); await store.create(title: 'B', type: 'task', column: 'done'); await store.create(title: 'C', type: 'bug', column: 'todo'); final s = await store.stats(); expect(s['total'], 3); expect((s['byColumn'] as Map)['todo'], 2); expect((s['byColumn'] as Map)['done'], 1); expect((s['byType'] as Map)['task'], 2); expect((s['byType'] as Map)['bug'], 1); }); }); }