import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tasq/models/programmer_task.model.dart'; import 'package:tasq/providers/programmer_task_comments_provider.dart'; import 'package:tasq/providers/programmer_task_work_logs_provider.dart'; import 'package:tasq/providers/programmer_tasks_provider.dart'; import 'package:tasq/screens/programmer_tasks/programmer_task_actions.dart'; import 'package:tasq/utils/app_time.dart'; // --------------------------------------------------------------------------- // Minimal fake Supabase client — only the surface ProgrammerTasksController uses. // --------------------------------------------------------------------------- class _FakeUser { _FakeUser(this.id); final String id; } class _FakeAuth { _FakeAuth(this._id); final String _id; _FakeUser? get currentUser => _FakeUser(_id); } class _FakeClient { _FakeClient(this.userId); final String userId; int _seq = 0; final Map>> tables = { 'programmer_tasks': [], 'programmer_task_activity_logs': [], 'programmer_task_work_logs': [], 'programmer_task_comments': [], }; _FakeAuth get auth => _FakeAuth(userId); _FakeQuery from(String table) => _FakeQuery(this, table); String nextTimestamp() { _seq += 1; return DateTime.utc(2026, 1, 1).add(Duration(seconds: _seq)).toIso8601String(); } } class _FakeQuery implements Future>> { _FakeQuery(this.client, this.table); final _FakeClient client; final String table; final List> _eqs = []; String? _inField; List? _inValues; String? _orderField; bool _orderAsc = true; int? _limit; Map? _insertPayload; Map? _updatePayload; List> get _rows { var rows = List>.from(client.tables[table] ?? []); if (_inField != null && _inValues != null) { rows = rows .where((r) => _inValues!.contains(r[_inField]?.toString())) .toList(); } for (final eq in _eqs) { rows = rows.where((r) => r[eq.key] == eq.value).toList(); } if (_orderField != null) { rows.sort((a, b) { final av = '${a[_orderField]}'; final bv = '${b[_orderField]}'; return _orderAsc ? av.compareTo(bv) : bv.compareTo(av); }); } if (_limit != null && rows.length > _limit!) { rows = rows.sublist(0, _limit!); } return rows; } Future>> get _future async { if (_insertPayload != null) { final row = Map.from(_insertPayload!); row['id'] ??= '${table}_${client.tables[table]!.length + 1}'; row['created_at'] ??= client.nextTimestamp(); if (table == 'programmer_tasks') { row['updated_at'] ??= row['created_at']; } client.tables[table]!.add(row); return [row]; } return _rows; } _FakeQuery select([String? _]) => this; _FakeQuery insert(Map payload) { _insertPayload = Map.from(payload); return this; } _FakeQuery update(Map payload) { _updatePayload = Map.from(payload); return this; } _FakeQuery inFilter(String field, List values) { _inField = field; _inValues = values.map((v) => v.toString()).toList(); return this; } _FakeQuery order(String field, {bool ascending = true}) { _orderField = field; _orderAsc = ascending; return this; } _FakeQuery limit(int n) { _limit = n; return this; } _FakeQuery eq(String field, dynamic value) { if (_updatePayload != null) { final idx = client.tables[table]!.indexWhere((r) => r[field] == value); if (idx >= 0) { client.tables[table]![idx] = { ...client.tables[table]![idx], ..._updatePayload!, }; } _updatePayload = null; return this; } _eqs.add(MapEntry(field, value)); return this; } Future?> maybeSingle() async { final rows = _rows; return rows.isEmpty ? null : Map.from(rows.first); } // Future delegation. @override Stream>> asStream() => _future.asStream(); @override Future>> catchError(Function onError, {bool Function(Object error)? test}) => _future.catchError(onError, test: test); @override Future then(FutureOr Function(List>) onValue, {Function? onError}) => _future.then(onValue, onError: onError); @override Future>> timeout(Duration timeLimit, {FutureOr>> Function()? onTimeout}) => _future.timeout(timeLimit, onTimeout: onTimeout); @override Future>> whenComplete(FutureOr Function() a) => _future.whenComplete(a); } List> _logs(_FakeClient c) => c.tables['programmer_task_activity_logs']!; Map _task(_FakeClient c, String id) => c.tables['programmer_tasks']!.firstWhere((t) => t['id'] == id); void main() { setUpAll(AppTime.initialize); group('ProgrammerTasksController.createTask', () { test('self-logged task: assignee = creator, no "assigned" log', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 'Fix login bug', category: ProgrammerTaskCategory.bugFix, ); final row = _task(fake, id); expect(row['assignee_id'], 'u1'); expect(row['creator_id'], 'u1'); expect(row['status'], ProgrammerTaskStatus.queued); expect(_logs(fake).where((l) => l['action_type'] == 'created').length, 1); expect(_logs(fake).any((l) => l['action_type'] == 'assigned'), isFalse); }); test('assigned to another programmer records an "assigned" log', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 'Build report export', category: ProgrammerTaskCategory.softwareDevelopment, assigneeId: 'u2', ); expect(_task(fake, id)['assignee_id'], 'u2'); final assigned = _logs(fake).where((l) => l['action_type'] == 'assigned').toList(); expect(assigned.length, 1); expect(assigned.first['meta'], {'assignee_id': 'u2'}); }); }); group('findRunningTaskForCurrentUser (pause-on-switch detection)', () { test('returns null when the user has no in_progress task', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); fake.tables['programmer_tasks']!.add({ 'id': 't1', 'assignee_id': 'u1', 'status': ProgrammerTaskStatus.queued, 'created_at': fake.nextTimestamp(), }); expect(await c.findRunningTaskForCurrentUser(), isNull); }); test('returns an in_progress task whose latest event is "started"', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); fake.tables['programmer_tasks']!.add({ 'id': 't1', 'title': 'Running task', 'category': ProgrammerTaskCategory.others, 'assignee_id': 'u1', 'status': ProgrammerTaskStatus.inProgress, 'created_at': fake.nextTimestamp(), }); _logs(fake).add({ 'task_id': 't1', 'action_type': 'started', 'created_at': fake.nextTimestamp(), }); final running = await c.findRunningTaskForCurrentUser(); expect(running, isNotNull); expect(running!.id, 't1'); }); test('a paused in_progress task does NOT count as running', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); fake.tables['programmer_tasks']!.add({ 'id': 't1', 'title': 'Paused task', 'category': ProgrammerTaskCategory.others, 'assignee_id': 'u1', 'status': ProgrammerTaskStatus.inProgress, 'created_at': fake.nextTimestamp(), }); _logs(fake).add({ 'task_id': 't1', 'action_type': 'started', 'created_at': fake.nextTimestamp(), }); _logs(fake).add({ 'task_id': 't1', 'action_type': 'paused', 'created_at': fake.nextTimestamp(), }); expect(await c.findRunningTaskForCurrentUser(), isNull); }); }); group('lifecycle transitions', () { test('startTask sets in_progress + started_at + a "started" log', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.meeting, ); await c.startTask(taskId: id); final row = _task(fake, id); expect(row['status'], ProgrammerTaskStatus.inProgress); expect(row['started_at'], isNotNull); expect(_logs(fake).any((l) => l['action_type'] == 'started'), isTrue); }); test('pauseTask is idempotent while already paused', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.startTask(taskId: id); await c.pauseTask(taskId: id); await c.pauseTask(taskId: id); // second call is a no-op expect(_logs(fake).where((l) => l['action_type'] == 'paused').length, 1); }); test('resumeTask logs a "resumed" event after a pause', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.startTask(taskId: id); await c.pauseTask(taskId: id); await c.resumeTask(taskId: id); expect(_logs(fake).where((l) => l['action_type'] == 'resumed').length, 1); }); test('completeTask sets completed status + timestamp + log', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.startTask(taskId: id); await c.completeTask(taskId: id); final row = _task(fake, id); expect(row['status'], ProgrammerTaskStatus.completed); expect(row['completed_at'], isNotNull); expect(_logs(fake).any((l) => l['action_type'] == 'completed'), isTrue); }); test('cancelTask stores the reason and a "cancelled" log', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.cancelTask(taskId: id, reason: 'Descoped'); final row = _task(fake, id); expect(row['status'], ProgrammerTaskStatus.cancelled); expect(row['cancellation_reason'], 'Descoped'); expect(_logs(fake).any((l) => l['action_type'] == 'cancelled'), isTrue); }); test('reassign updates assignee and logs a "reassigned" event', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.reassign(taskId: id, newAssigneeId: 'u2'); expect(_task(fake, id)['assignee_id'], 'u2'); expect(_logs(fake).any((l) => l['action_type'] == 'reassigned'), isTrue); }); }); group('startProgrammerTaskWithPausePrompt (pause-on-switch UX)', () { _FakeClient seededSwitchState() { final fake = _FakeClient('u1'); // Task A is actively running for u1. fake.tables['programmer_tasks']!.add({ 'id': 'A', 'title': 'Task A', 'category': ProgrammerTaskCategory.others, 'assignee_id': 'u1', 'status': ProgrammerTaskStatus.inProgress, 'started_at': fake.nextTimestamp(), 'created_at': fake.nextTimestamp(), }); _logs(fake).add({ 'task_id': 'A', 'action_type': 'started', 'created_at': fake.nextTimestamp(), }); // Task B is queued and about to be started. fake.tables['programmer_tasks']!.add({ 'id': 'B', 'title': 'Task B', 'category': ProgrammerTaskCategory.others, 'assignee_id': 'u1', 'status': ProgrammerTaskStatus.queued, 'created_at': fake.nextTimestamp(), }); return fake; } ProgrammerTask taskB() => ProgrammerTask( id: 'B', title: 'Task B', category: ProgrammerTaskCategory.others, status: ProgrammerTaskStatus.queued, assigneeId: 'u1', creatorId: 'u1', createdAt: DateTime.utc(2026, 1, 1), updatedAt: DateTime.utc(2026, 1, 1), ); Widget harness(_FakeClient fake) => ProviderScope( overrides: [ programmerTasksControllerProvider.overrideWithValue( ProgrammerTasksController(fake as dynamic), ), ], child: MaterialApp( home: Scaffold( body: Consumer( builder: (context, ref, _) => ElevatedButton( onPressed: () => startProgrammerTaskWithPausePrompt(context, ref, taskB()), child: const Text('Start B'), ), ), ), ), ); testWidgets('prompts, then pauses A and starts B on confirm', (tester) async { final fake = seededSwitchState(); await tester.pumpWidget(harness(fake)); await tester.tap(find.text('Start B')); await tester.pumpAndSettle(); // The prompt names the currently-running task. expect(find.text('Pause current task?'), findsOneWidget); await tester.tap(find.text('Pause & Start')); await tester.pumpAndSettle(); expect( _logs(fake).any((l) => l['task_id'] == 'A' && l['action_type'] == 'paused'), isTrue, ); expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.inProgress); }); testWidgets('cancel leaves both tasks untouched', (tester) async { final fake = seededSwitchState(); await tester.pumpWidget(harness(fake)); await tester.tap(find.text('Start B')); await tester.pumpAndSettle(); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(_logs(fake).any((l) => l['action_type'] == 'paused'), isFalse); expect(_task(fake, 'B')['status'], ProgrammerTaskStatus.queued); }); }); group('projects, adjustments & collaboration', () { test('createTask carries project_id', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, projectId: 'proj-1', ); expect(_task(fake, id)['project_id'], 'proj-1'); }); test('setProject updates project_id and logs an update', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.setProject(taskId: id, projectId: 'proj-9'); expect(_task(fake, id)['project_id'], 'proj-9'); expect( _logs(fake).any((l) => l['action_type'] == 'updated' && (l['meta']?['fields'] as List?)?.contains('project_id') == true), isTrue, ); }); test('addAdjustment records a signed adjustment log', () async { final fake = _FakeClient('u1'); final c = ProgrammerTasksController(fake as dynamic); final id = await c.createTask( title: 't', category: ProgrammerTaskCategory.others, ); await c.addAdjustment( taskId: id, seconds: 1800, reason: 'helped', sourceTaskId: 'other', ); final adj = _logs(fake).firstWhere((l) => l['action_type'] == 'adjustment'); expect(adj['meta']['seconds'], 1800); expect(adj['meta']['reason'], 'helped'); expect(adj['meta']['source_task_id'], 'other'); }); test('addWorkLog stores helper minutes and omits them for the assignee', () async { final fake = _FakeClient('u1'); final wl = ProgrammerTaskWorkLogsController(fake as dynamic); await wl.addWorkLog(taskId: 't1', description: 'helped', minutes: 30); await wl.addWorkLog(taskId: 't1', description: 'progress'); // assignee note final rows = fake.tables['programmer_task_work_logs']!; expect(rows.length, 2); expect(rows[0]['author_id'], 'u1'); expect(rows[0]['minutes'], 30); expect(rows[1].containsKey('minutes'), isFalse); // null minutes omitted }); test('addComment stores body + author with empty attachments', () async { final fake = _FakeClient('u1'); final cc = ProgrammerTaskCommentsController(fake as dynamic); await cc.addComment(taskId: 't1', body: 'looks good'); final rows = fake.tables['programmer_task_comments']!; expect(rows.length, 1); expect(rows.first['author_id'], 'u1'); expect(rows.first['body'], 'looks good'); expect(rows.first['attachments'], isEmpty); }); }); }