be16a3cd0d
Co-Authored-By: claude-flow <ruv@ruv.net>
564 lines
18 KiB
Dart
564 lines
18 KiB
Dart
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<String, List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> {
|
|
_FakeQuery(this.client, this.table);
|
|
|
|
final _FakeClient client;
|
|
final String table;
|
|
|
|
final List<MapEntry<String, dynamic>> _eqs = [];
|
|
String? _inField;
|
|
List<String>? _inValues;
|
|
String? _orderField;
|
|
bool _orderAsc = true;
|
|
int? _limit;
|
|
Map<String, dynamic>? _insertPayload;
|
|
Map<String, dynamic>? _updatePayload;
|
|
|
|
List<Map<String, dynamic>> get _rows {
|
|
var rows = List<Map<String, dynamic>>.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<List<Map<String, dynamic>>> get _future async {
|
|
if (_insertPayload != null) {
|
|
final row = Map<String, dynamic>.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<String, dynamic> payload) {
|
|
_insertPayload = Map<String, dynamic>.from(payload);
|
|
return this;
|
|
}
|
|
|
|
_FakeQuery update(Map<String, dynamic> payload) {
|
|
_updatePayload = Map<String, dynamic>.from(payload);
|
|
return this;
|
|
}
|
|
|
|
_FakeQuery inFilter(String field, List<dynamic> 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<Map<String, dynamic>?> maybeSingle() async {
|
|
final rows = _rows;
|
|
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
|
|
}
|
|
|
|
// Future delegation.
|
|
@override
|
|
Stream<List<Map<String, dynamic>>> asStream() => _future.asStream();
|
|
@override
|
|
Future<List<Map<String, dynamic>>> catchError(Function onError,
|
|
{bool Function(Object error)? test}) =>
|
|
_future.catchError(onError, test: test);
|
|
@override
|
|
Future<R> then<R>(FutureOr<R> Function(List<Map<String, dynamic>>) onValue,
|
|
{Function? onError}) =>
|
|
_future.then(onValue, onError: onError);
|
|
@override
|
|
Future<List<Map<String, dynamic>>> timeout(Duration timeLimit,
|
|
{FutureOr<List<Map<String, dynamic>>> Function()? onTimeout}) =>
|
|
_future.timeout(timeLimit, onTimeout: onTimeout);
|
|
@override
|
|
Future<List<Map<String, dynamic>>> whenComplete(FutureOr<void> Function() a) =>
|
|
_future.whenComplete(a);
|
|
}
|
|
|
|
List<Map<String, dynamic>> _logs(_FakeClient c) =>
|
|
c.tables['programmer_task_activity_logs']!;
|
|
Map<String, dynamic> _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('addWorkLog with workDate inserts work_date key', () async {
|
|
final fake = _FakeClient('u1');
|
|
final wl = ProgrammerTaskWorkLogsController(fake as dynamic);
|
|
await wl.addWorkLog(
|
|
taskId: 't1',
|
|
description: 'correction',
|
|
workDate: DateTime.utc(2026, 9, 25),
|
|
);
|
|
final rows = fake.tables['programmer_task_work_logs']!;
|
|
expect(rows.length, 1);
|
|
expect(rows.first['work_date'], '2026-09-25');
|
|
});
|
|
|
|
test('addWorkLog without workDate omits work_date key', () async {
|
|
final fake = _FakeClient('u1');
|
|
final wl = ProgrammerTaskWorkLogsController(fake as dynamic);
|
|
await wl.addWorkLog(taskId: 't1', description: 'progress');
|
|
final rows = fake.tables['programmer_task_work_logs']!;
|
|
expect(rows.length, 1);
|
|
expect(rows.first.containsKey('work_date'), isFalse);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|