From a1c36f7136e32a9c0b1dbdc06cf4ce65a0e86f80 Mon Sep 17 00:00:00 2001 From: Chris Hendrickson Date: Thu, 23 Apr 2026 14:47:25 -0400 Subject: [PATCH] Implement kanban subcommands (create, get, update, delete) - Add ProjectContext and DewConfig models to core - Add Ticket model with YAML frontmatter serialisation - Add TicketStore with auto-incrementing 4-digit IDs - Implement create/get/update/delete subcommands under KanbanCommand - Expand tests: ProjectContext, Ticket roundtrip, TicketStore CRUD (19 total) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/lib/dew_core.dart | 1 + packages/core/lib/src/config.dart | 120 +++++++++++++++++ packages/core/pubspec.yaml | 1 + packages/core/test/dew_core_test.dart | 59 +++++++- packages/kanban/lib/dew_kanban.dart | 2 + .../lib/src/commands/create_command.dart | 68 ++++++++++ .../lib/src/commands/delete_command.dart | 32 +++++ .../kanban/lib/src/commands/get_command.dart | 42 ++++++ .../lib/src/commands/update_command.dart | 71 ++++++++++ packages/kanban/lib/src/dew_kanban_base.dart | 12 ++ packages/kanban/lib/src/ticket.dart | 102 ++++++++++++++ packages/kanban/lib/src/ticket_store.dart | 100 ++++++++++++++ packages/kanban/pubspec.yaml | 1 + packages/kanban/test/dew_kanban_test.dart | 126 +++++++++++++++++- 14 files changed, 734 insertions(+), 3 deletions(-) create mode 100644 packages/core/lib/src/config.dart create mode 100644 packages/kanban/lib/src/commands/create_command.dart create mode 100644 packages/kanban/lib/src/commands/delete_command.dart create mode 100644 packages/kanban/lib/src/commands/get_command.dart create mode 100644 packages/kanban/lib/src/commands/update_command.dart create mode 100644 packages/kanban/lib/src/ticket.dart create mode 100644 packages/kanban/lib/src/ticket_store.dart diff --git a/packages/core/lib/dew_core.dart b/packages/core/lib/dew_core.dart index 9376088..c5bb97e 100644 --- a/packages/core/lib/dew_core.dart +++ b/packages/core/lib/dew_core.dart @@ -1,3 +1,4 @@ library; +export 'src/config.dart'; export 'src/dew_core_base.dart'; diff --git a/packages/core/lib/src/config.dart b/packages/core/lib/src/config.dart new file mode 100644 index 0000000..df0e54a --- /dev/null +++ b/packages/core/lib/src/config.dart @@ -0,0 +1,120 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +class TicketTypeConfig { + final String id; + final String name; + + const TicketTypeConfig({required this.id, required this.name}); +} + +class ColumnConfig { + final String id; + final String name; + final String color; + + const ColumnConfig({ + required this.id, + required this.name, + required this.color, + }); +} + +class KanbanConfig { + final String prefix; + final List ticketTypes; + final List columns; + + const KanbanConfig({ + required this.prefix, + required this.ticketTypes, + required this.columns, + }); +} + +class McpConfig { + final String host; + final int port; + + const McpConfig({required this.host, required this.port}); +} + +class DewConfig { + final KanbanConfig kanban; + final McpConfig mcp; + + const DewConfig({required this.kanban, required this.mcp}); + + factory DewConfig.fromYaml(YamlMap yaml) { + final dew = yaml['dew'] as YamlMap; + + final mcpYaml = dew['mcp'] as YamlMap; + final mcp = McpConfig( + host: mcpYaml['host'] as String, + port: mcpYaml['port'] as int, + ); + + final kanbanYaml = dew['kanban'] as YamlMap; + final ticketTypes = + (kanbanYaml['ticket_types'] as YamlList) + .map( + (t) => TicketTypeConfig( + id: t['id'] as String, + name: t['name'] as String, + ), + ) + .toList(); + final columns = + (kanbanYaml['columns'] as YamlList) + .map( + (c) => ColumnConfig( + id: c['id'] as String, + name: c['name'] as String, + color: c['color'] as String, + ), + ) + .toList(); + + return DewConfig( + kanban: KanbanConfig( + prefix: kanbanYaml['prefix'] as String, + ticketTypes: ticketTypes, + columns: columns, + ), + mcp: mcp, + ); + } +} + +/// Locates the nearest project root and exposes the parsed [DewConfig]. +class ProjectContext { + final String root; + final DewConfig config; + + const ProjectContext({required this.root, required this.config}); + + /// Walks up from [Directory.current] until a `.project/dew.yaml` is found. + static Future find() async { + var dir = Directory.current; + while (true) { + final configFile = File(p.join(dir.path, '.project', 'dew.yaml')); + if (await configFile.exists()) { + final yaml = loadYaml(await configFile.readAsString()) as YamlMap; + return ProjectContext( + root: dir.path, + config: DewConfig.fromYaml(yaml), + ); + } + final parent = dir.parent; + if (parent.path == dir.path) { + throw StateError( + 'Could not find .project/dew.yaml. ' + 'Run "dew init ." to initialise a project here.', + ); + } + dir = parent; + } + } +} diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index b85c23a..8fec53e 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: args: ^2.7.0 path: ^1.9.0 + yaml: ^3.1.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/core/test/dew_core_test.dart b/packages/core/test/dew_core_test.dart index f8ca8e5..06bbe4a 100644 --- a/packages/core/test/dew_core_test.dart +++ b/packages/core/test/dew_core_test.dart @@ -1,7 +1,9 @@ +import 'dart:io'; + import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; -// Minimal concrete command for testing the registry. class _TestCommand extends DewCommand { @override final String name = 'test-cmd'; @@ -27,7 +29,60 @@ void main() { test('commands list is unmodifiable', () { final registry = CommandRegistry(); - expect(() => registry.commands.add(_TestCommand()), throwsUnsupportedError); + expect( + () => registry.commands.add(_TestCommand()), + throwsUnsupportedError, + ); + }); + }); + + group('ProjectContext', () { + late Directory tempDir; + late Directory originalDir; + + setUp(() async { + originalDir = Directory.current; + tempDir = await Directory.systemTemp.createTemp('dew_core_test_'); + await Directory(p.join(tempDir.path, '.project')).create(); + await File( + p.join(tempDir.path, '.project', 'dew.yaml'), + ).writeAsString(''' +dew: + mcp: + host: localhost + port: 9090 + kanban: + prefix: TEST + ticket_types: + - id: task + name: Task + columns: + - id: todo + name: To Do + color: blue +'''); + Directory.current = tempDir; + }); + + tearDown(() async { + Directory.current = originalDir; + await tempDir.delete(recursive: true); + }); + + test('find() loads config from .project/dew.yaml', () async { + final ctx = await ProjectContext.find(); + expect(ctx.config.kanban.prefix, 'TEST'); + expect(ctx.config.kanban.ticketTypes, hasLength(1)); + expect(ctx.config.kanban.columns.first.id, 'todo'); + expect(ctx.config.mcp.host, 'localhost'); + expect(ctx.config.mcp.port, 9090); + }); + + test('find() locates config from a subdirectory', () async { + final sub = await Directory(p.join(tempDir.path, 'sub')).create(); + Directory.current = sub; + final ctx = await ProjectContext.find(); + expect(ctx.root, tempDir.path); }); }); } diff --git a/packages/kanban/lib/dew_kanban.dart b/packages/kanban/lib/dew_kanban.dart index 83ceff2..06a7589 100644 --- a/packages/kanban/lib/dew_kanban.dart +++ b/packages/kanban/lib/dew_kanban.dart @@ -1,6 +1,8 @@ library; export 'src/dew_kanban_base.dart'; +export 'src/ticket.dart'; +export 'src/ticket_store.dart'; import 'package:dew_core/dew_core.dart'; import 'package:dew_kanban/src/dew_kanban_base.dart'; diff --git a/packages/kanban/lib/src/commands/create_command.dart b/packages/kanban/lib/src/commands/create_command.dart new file mode 100644 index 0000000..15bf846 --- /dev/null +++ b/packages/kanban/lib/src/commands/create_command.dart @@ -0,0 +1,68 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class CreateCommand extends DewCommand { + CreateCommand() { + argParser + ..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.') + ..addOption( + 'type', + mandatory: true, + help: 'Ticket type (e.g. task, bug).', + ) + ..addOption( + 'column', + abbr: 'c', + help: 'Initial column. Defaults to the first configured column.', + ) + ..addOption('body', abbr: 'b', help: 'Ticket description.'); + } + + @override + final String name = 'create'; + + @override + final String description = 'Create a new kanban ticket.'; + + @override + Future run() async { + final context = await ProjectContext.find(); + final config = context.config.kanban; + + final title = argResults!['title'] as String; + final typeId = argResults!['type'] as String; + final columnArg = argResults!['column'] as String?; + final body = argResults!['body'] as String? ?? ''; + + if (!config.ticketTypes.any((t) => t.id == typeId)) { + usageException( + 'Unknown type "$typeId". ' + 'Valid types: ${config.ticketTypes.map((t) => t.id).join(', ')}', + ); + } + + final column = columnArg ?? config.columns.first.id; + if (!config.columns.any((c) => c.id == column)) { + usageException( + 'Unknown column "$column". ' + 'Valid columns: ${config.columns.map((c) => c.id).join(', ')}', + ); + } + + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: config.prefix, + ); + + final ticket = await store.create( + title: title, + type: typeId, + column: column, + body: body, + ); + + print('Created ${ticket.id}.'); + } +} diff --git a/packages/kanban/lib/src/commands/delete_command.dart b/packages/kanban/lib/src/commands/delete_command.dart new file mode 100644 index 0000000..c43f3c0 --- /dev/null +++ b/packages/kanban/lib/src/commands/delete_command.dart @@ -0,0 +1,32 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class DeleteCommand extends DewCommand { + @override + final String name = 'delete'; + + @override + final String description = 'Delete a kanban ticket.'; + + @override + Future run() async { + final rest = argResults!.rest; + if (rest.isEmpty) usageException('Ticket ID is required.'); + final id = rest.first.toUpperCase(); + + final context = await ProjectContext.find(); + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: context.config.kanban.prefix, + ); + + try { + await store.delete(id); + print('Deleted $id.'); + } on ArgumentError catch (e) { + usageException(e.message as String); + } + } +} diff --git a/packages/kanban/lib/src/commands/get_command.dart b/packages/kanban/lib/src/commands/get_command.dart new file mode 100644 index 0000000..f980113 --- /dev/null +++ b/packages/kanban/lib/src/commands/get_command.dart @@ -0,0 +1,42 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class GetCommand extends DewCommand { + @override + final String name = 'get'; + + @override + final String description = 'Get a kanban ticket by ID.'; + + @override + Future run() async { + final rest = argResults!.rest; + if (rest.isEmpty) usageException('Ticket ID is required.'); + final id = rest.first.toUpperCase(); + + final context = await ProjectContext.find(); + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: context.config.kanban.prefix, + ); + + final ticket = await store.findById(id); + if (ticket == null) usageException('Ticket $id not found.'); + + print('[${ticket.id}] (${ticket.type}) [${ticket.column}] ${ticket.title}'); + print('Created: ${ticket.created.toLocal().toString().split('.').first}'); + + if (ticket.body.isNotEmpty) { + print(''); + print(ticket.body); + } + + for (final (i, comment) in ticket.comments.indexed) { + print(''); + print('── Comment ${i + 1} ${'─' * 20}'); + print(comment); + } + } +} diff --git a/packages/kanban/lib/src/commands/update_command.dart b/packages/kanban/lib/src/commands/update_command.dart new file mode 100644 index 0000000..ff4f374 --- /dev/null +++ b/packages/kanban/lib/src/commands/update_command.dart @@ -0,0 +1,71 @@ +import 'package:dew_core/dew_core.dart'; +import 'package:path/path.dart' as p; + +import '../ticket_store.dart'; + +class UpdateCommand extends DewCommand { + UpdateCommand() { + argParser + ..addOption('title', abbr: 't', help: 'New title.') + ..addOption('type', help: 'New ticket type.') + ..addOption('column', abbr: 'c', help: 'New column.') + ..addOption('body', abbr: 'b', help: 'New body (replaces existing).'); + } + + @override + final String name = 'update'; + + @override + final String description = 'Update a kanban ticket.'; + + @override + Future run() async { + final rest = argResults!.rest; + if (rest.isEmpty) usageException('Ticket ID is required.'); + final id = rest.first.toUpperCase(); + + final title = argResults!['title'] as String?; + final typeId = argResults!['type'] as String?; + final column = argResults!['column'] as String?; + final body = argResults!['body'] as String?; + + if (title == null && typeId == null && column == null && body == null) { + usageException('At least one option must be specified.'); + } + + final context = await ProjectContext.find(); + final config = context.config.kanban; + + if (typeId != null && !config.ticketTypes.any((t) => t.id == typeId)) { + usageException( + 'Unknown type "$typeId". ' + 'Valid types: ${config.ticketTypes.map((t) => t.id).join(', ')}', + ); + } + + if (column != null && !config.columns.any((c) => c.id == column)) { + usageException( + 'Unknown column "$column". ' + 'Valid columns: ${config.columns.map((c) => c.id).join(', ')}', + ); + } + + final store = TicketStore( + kanbanDir: p.join(context.root, '.project', 'kanban'), + prefix: config.prefix, + ); + + try { + final ticket = await store.update( + id, + title: title, + type: typeId, + column: column, + body: body, + ); + print('Updated ${ticket.id}.'); + } on ArgumentError catch (e) { + usageException(e.message as String); + } + } +} diff --git a/packages/kanban/lib/src/dew_kanban_base.dart b/packages/kanban/lib/src/dew_kanban_base.dart index 31cf860..49afa3c 100644 --- a/packages/kanban/lib/src/dew_kanban_base.dart +++ b/packages/kanban/lib/src/dew_kanban_base.dart @@ -1,7 +1,19 @@ import 'package:dew_core/dew_core.dart'; +import 'commands/create_command.dart'; +import 'commands/delete_command.dart'; +import 'commands/get_command.dart'; +import 'commands/update_command.dart'; + /// Top-level CLI command for all Kanban board operations. class KanbanCommand extends DewCommand { + KanbanCommand() { + addSubcommand(CreateCommand()); + addSubcommand(GetCommand()); + addSubcommand(UpdateCommand()); + addSubcommand(DeleteCommand()); + } + @override final String name = 'kanban'; diff --git a/packages/kanban/lib/src/ticket.dart b/packages/kanban/lib/src/ticket.dart new file mode 100644 index 0000000..4b18283 --- /dev/null +++ b/packages/kanban/lib/src/ticket.dart @@ -0,0 +1,102 @@ +import 'package:yaml/yaml.dart'; + +class Ticket { + final String id; + final String title; + final String type; + final String column; + final DateTime created; + final String body; + final List comments; + + const Ticket({ + required this.id, + required this.title, + required this.type, + required this.column, + required this.created, + required this.body, + required this.comments, + }); + + Ticket copyWith({ + String? title, + String? type, + String? column, + String? body, + List? comments, + }) => Ticket( + id: id, + title: title ?? this.title, + type: type ?? this.type, + column: column ?? this.column, + created: created, + body: body ?? this.body, + comments: comments ?? this.comments, + ); + + /// Serialises the ticket to markdown with YAML frontmatter. + /// + /// Format: + /// ``` + /// --- + /// id: DEW-0001 + /// title: My ticket + /// ... + /// --- + /// + /// Body text. + /// + /// --- + /// + /// Comment text. + /// ``` + String toFileContent() { + final buf = StringBuffer(); + buf.writeln('---'); + buf.writeln('id: $id'); + buf.writeln('title: $title'); + buf.writeln('type: $type'); + buf.writeln('column: $column'); + buf.writeln('created: ${created.toUtc().toIso8601String()}'); + buf.writeln('---'); + if (body.isNotEmpty) { + buf.writeln(); + buf.writeln(body); + } + for (final comment in comments) { + buf.writeln(); + buf.writeln('---'); + buf.writeln(); + buf.writeln(comment); + } + return buf.toString(); + } + + static Ticket fromFileContent(String id, String content) { + if (!content.startsWith('---\n')) { + throw FormatException('Ticket file $id does not start with ---'); + } + final fmEnd = content.indexOf('\n---\n', 4); + if (fmEnd == -1) { + throw FormatException('Ticket file $id is missing closing frontmatter ---'); + } + + final fm = loadYaml(content.substring(4, fmEnd)) as YamlMap; + + // Everything after the closing \n---\n, split into body + comments. + final rest = content.substring(fmEnd + 5); + final sections = + rest.split('\n---\n').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + + return Ticket( + id: id, + title: fm['title'] as String, + type: fm['type'] as String, + column: fm['column'] as String, + created: DateTime.parse(fm['created'] as String), + body: sections.isNotEmpty ? sections[0] : '', + comments: sections.length > 1 ? sections.sublist(1) : const [], + ); + } +} diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart new file mode 100644 index 0000000..f1affb5 --- /dev/null +++ b/packages/kanban/lib/src/ticket_store.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'ticket.dart'; + +class TicketStore { + final String kanbanDir; + final String prefix; + + const TicketStore({required this.kanbanDir, required this.prefix}); + + Future create({ + required String title, + required String type, + required String column, + String body = '', + }) async { + await Directory(kanbanDir).create(recursive: true); + final id = _formatId(await _nextNumber()); + final ticket = Ticket( + id: id, + title: title, + type: type, + column: column, + created: DateTime.now().toUtc(), + body: body, + comments: const [], + ); + await File(_filePath(id)).writeAsString(ticket.toFileContent()); + return ticket; + } + + Future findById(String id) async { + final file = File(_filePath(id)); + if (!await file.exists()) return null; + return Ticket.fromFileContent(id, await file.readAsString()); + } + + Future> list() async { + final dir = Directory(kanbanDir); + if (!await dir.exists()) return const []; + final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); + final tickets = []; + await for (final entity in dir.list()) { + final name = p.basename(entity.path); + if (pattern.hasMatch(name)) { + final id = p.basenameWithoutExtension(name); + final ticket = await findById(id); + if (ticket != null) tickets.add(ticket); + } + } + tickets.sort((a, b) => a.id.compareTo(b.id)); + return tickets; + } + + Future update( + String id, { + String? title, + String? type, + String? column, + String? body, + }) async { + final ticket = await findById(id); + if (ticket == null) throw ArgumentError('Ticket $id not found.'); + final updated = ticket.copyWith( + title: title, + type: type, + column: column, + body: body, + ); + await File(_filePath(id)).writeAsString(updated.toFileContent()); + return updated; + } + + Future delete(String id) async { + final file = File(_filePath(id)); + if (!await file.exists()) throw ArgumentError('Ticket $id not found.'); + await file.delete(); + } + + Future _nextNumber() async { + final dir = Directory(kanbanDir); + if (!await dir.exists()) return 1; + final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); + var max = 0; + await for (final entity in dir.list()) { + final match = pattern.firstMatch(p.basename(entity.path)); + if (match != null) { + final n = int.parse(match.group(1)!); + if (n > max) max = n; + } + } + return max + 1; + } + + String _formatId(int n) => '$prefix-${n.toString().padLeft(4, '0')}'; + + String _filePath(String id) => p.join(kanbanDir, '$id.md'); +} diff --git a/packages/kanban/pubspec.yaml b/packages/kanban/pubspec.yaml index 2997417..c48471a 100644 --- a/packages/kanban/pubspec.yaml +++ b/packages/kanban/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: dew_core: path: ../core path: ^1.9.0 + yaml: ^3.1.0 dev_dependencies: lints: ^6.0.0 diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index 6d163bb..137e7b2 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -1,5 +1,8 @@ -import 'package:dew_kanban/dew_kanban.dart'; +import 'dart:io'; + import 'package:dew_core/dew_core.dart'; +import 'package:dew_kanban/dew_kanban.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; void main() { @@ -10,10 +13,131 @@ void main() { expect(cmd.description, isNotEmpty); }); + test('has expected subcommands', () { + final cmd = KanbanCommand(); + expect( + cmd.subcommands.keys, + containsAll(['create', 'get', 'update', 'delete']), + ); + }); + test('registerCommands adds kanban command to registry', () { final registry = CommandRegistry(); registerCommands(registry); expect(registry.commands.map((c) => c.name), contains('kanban')); }); }); + + 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()); + 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()); + expect(parsed.body, ''); + expect(parsed.comments, isEmpty); + }); + }); + + group('TicketStore', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('dew_kanban_test_'); + }); + + tearDown(() => tempDir.delete(recursive: true)); + + TicketStore makeStore() => TicketStore( + kanbanDir: p.join(tempDir.path, 'kanban'), + prefix: 'TEST', + ); + + test('create assigns incrementing IDs', () async { + final store = makeStore(); + 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 store = makeStore(); + expect(await store.findById('TEST-0099'), isNull); + }); + + test('list returns sorted tickets', () async { + final store = makeStore(); + 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('update patches specified fields', () async { + final store = makeStore(); + 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 store = makeStore(); + expect( + () => store.update('TEST-0099', title: 'X'), + throwsA(isA()), + ); + }); + + test('delete removes ticket', () async { + final store = makeStore(); + 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 store = makeStore(); + expect( + () => store.delete('TEST-0099'), + throwsA(isA()), + ); + }); + }); } +