Files
tasq/test/programmer_tasks_controller_test.dart
T
redz1029 627645056b Add Programmer Tasks module (work-log + time tracking)
Separate feature for the `programmer` role to record dev work by category,
distinct from the IT help-desk `tasks` feature. Modeled on the IT Service
Requests pattern (Brick offline-first model -> Riverpod stream/controller ->
list/detail screens); seed of a future Project Management workflow.

- programmer_tasks + programmer_task_activity_logs (category enum, RLS,
  realtime) via supabase migration
- single assignee, self-logged or lead-assigned; assign/reassign logged
- pause-on-switch: starting a task while another runs prompts to pause the
  running one, keeping one active task per user for accurate time attribution
- worked-duration math shared with help-desk tasks via a generic core in
  task_duration.dart (existing callers unchanged)
- list distinguishes running vs paused with a per-task Resume shortcut
- accessible dialog labels; route /programmer-tasks + nav gated to admin/programmer
- unit/widget tests: controller lifecycle, duration math, pause-switch dialog

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-27 01:13:33 +08:00

463 lines
15 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_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': [],
};
_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);
});
});
}