Auto Task Assignment
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/providers/tasks_provider.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'chooseAutoAssignCandidate picks latest check-in (late-comer priority)',
|
||||
() {
|
||||
final now = DateTime(2026, 2, 18, 12, 0, 0);
|
||||
final earlier = AutoAssignCandidate(
|
||||
userId: 'user-1',
|
||||
checkInAt: now.subtract(const Duration(hours: 2)),
|
||||
completedToday: 0,
|
||||
);
|
||||
final later = AutoAssignCandidate(
|
||||
userId: 'user-2',
|
||||
checkInAt: now.subtract(const Duration(hours: 1)),
|
||||
completedToday: 0,
|
||||
);
|
||||
|
||||
final chosen = chooseAutoAssignCandidate([earlier, later]);
|
||||
expect(chosen, equals('user-2'));
|
||||
},
|
||||
);
|
||||
|
||||
test('chooseAutoAssignCandidate uses completed count as tie-breaker', () {
|
||||
final now = DateTime(2026, 2, 18, 9, 0, 0);
|
||||
final a = AutoAssignCandidate(
|
||||
userId: 'a',
|
||||
checkInAt: now,
|
||||
completedToday: 5,
|
||||
);
|
||||
final b = AutoAssignCandidate(
|
||||
userId: 'b',
|
||||
checkInAt: now,
|
||||
completedToday: 2,
|
||||
);
|
||||
|
||||
final chosen = chooseAutoAssignCandidate([a, b]);
|
||||
expect(chosen, equals('b'));
|
||||
});
|
||||
|
||||
test('chooseAutoAssignCandidate returns null for empty list', () {
|
||||
expect(chooseAutoAssignCandidate([]), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/providers/tasks_provider.dart';
|
||||
|
||||
// Lightweight in-memory fake Supabase client used only for this test.
|
||||
class _FakeClient {
|
||||
final Map<String, List<Map<String, dynamic>>> tables = {
|
||||
'tickets': [],
|
||||
'tasks': [],
|
||||
'teams': [],
|
||||
'team_members': [],
|
||||
'duty_schedules': [],
|
||||
'task_assignments': [],
|
||||
'task_activity_logs': [],
|
||||
'notifications': [],
|
||||
};
|
||||
|
||||
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||
}
|
||||
|
||||
class _FakeQuery {
|
||||
final _FakeClient client;
|
||||
final String table;
|
||||
Map<String, dynamic>? _eq;
|
||||
List? _in;
|
||||
Map<String, dynamic>? _insertPayload;
|
||||
Map<String, dynamic>? _updatePayload;
|
||||
|
||||
_FakeQuery(this.client, this.table);
|
||||
|
||||
_FakeQuery select([String? _]) => this;
|
||||
_FakeQuery eq(String field, dynamic value) {
|
||||
_eq = {field: value};
|
||||
return this;
|
||||
}
|
||||
|
||||
_FakeQuery inFilter(String field, List values) {
|
||||
_in = values;
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> maybeSingle() async {
|
||||
final rows = client.tables[table] ?? [];
|
||||
if (_eq != null) {
|
||||
final field = _eq!.keys.first;
|
||||
final value = _eq![field];
|
||||
Map<String, dynamic>? found;
|
||||
for (final r in rows) {
|
||||
if (r[field] == value) {
|
||||
found = Map<String, dynamic>.from(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> execute() async {
|
||||
final rows = client.tables[table] ?? [];
|
||||
if (_in != null) {
|
||||
// return rows where the field (assume first) is in the list
|
||||
return rows
|
||||
.where((r) => _in!.contains(r.values.first))
|
||||
.map((r) => Map<String, dynamic>.from(r))
|
||||
.toList();
|
||||
}
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
_FakeQuery insert(Map<String, dynamic> payload) {
|
||||
_insertPayload = Map<String, dynamic>.from(payload);
|
||||
return this;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> single() async {
|
||||
if (_insertPayload != null) {
|
||||
// emulate DB-generated id
|
||||
final id = 'tsk-${client.tables['tasks']!.length + 1}';
|
||||
final row = Map<String, dynamic>.from(_insertPayload!);
|
||||
row['id'] = id;
|
||||
client.tables[table]!.add(row);
|
||||
return Map<String, dynamic>.from(row);
|
||||
}
|
||||
if (_updatePayload != null && _eq != null) {
|
||||
final field = _eq!.keys.first;
|
||||
final value = _eq![field];
|
||||
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
|
||||
if (idx >= 0) {
|
||||
client.tables[table]![idx] = {
|
||||
...client.tables[table]![idx],
|
||||
..._updatePayload!,
|
||||
};
|
||||
return Map<String, dynamic>.from(client.tables[table]![idx]);
|
||||
}
|
||||
}
|
||||
throw Exception('single() called in unsupported context in fake');
|
||||
}
|
||||
|
||||
Future<void> update(Map<String, dynamic> payload) async {
|
||||
_updatePayload = payload;
|
||||
if (_eq != null) {
|
||||
final field = _eq!.keys.first;
|
||||
final value = _eq![field];
|
||||
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
|
||||
if (idx >= 0) {
|
||||
client.tables[table]![idx] = {
|
||||
...client.tables[table]![idx],
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete() async {
|
||||
if (_eq != null) {
|
||||
final field = _eq!.keys.first;
|
||||
final value = _eq![field];
|
||||
client.tables[table]!.removeWhere((r) => r[field] == value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal adapter used by controllers in production; this mirrors the small
|
||||
// subset of API used by TicketsController/TasksController in tests.
|
||||
extension FakeClientAdapter on _FakeClient {
|
||||
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'ticket promotion creates task and auto-assigns late-comer',
|
||||
() async {
|
||||
final fake = _FakeClient();
|
||||
|
||||
// ticket row
|
||||
fake.tables['tickets']!.add({
|
||||
'id': 'TCK-1',
|
||||
'subject': 'Printer down',
|
||||
'description': 'Paper jam',
|
||||
'office_id': 'office-1',
|
||||
});
|
||||
|
||||
// team covering office-1
|
||||
fake.tables['teams']!.add({
|
||||
'id': 'team-1',
|
||||
'office_ids': ['office-1'],
|
||||
});
|
||||
|
||||
// two team members
|
||||
fake.tables['team_members']!.add({'team_id': 'team-1', 'user_id': 'u1'});
|
||||
fake.tables['team_members']!.add({'team_id': 'team-1', 'user_id': 'u2'});
|
||||
|
||||
// both checked in today; u2 arrived later -> should be chosen
|
||||
fake.tables['duty_schedules']!.add({
|
||||
'user_id': 'u1',
|
||||
'check_in_at': DateTime(2026, 2, 18, 8, 0).toIso8601String(),
|
||||
});
|
||||
fake.tables['duty_schedules']!.add({
|
||||
'user_id': 'u2',
|
||||
'check_in_at': DateTime(2026, 2, 18, 9, 0).toIso8601String(),
|
||||
});
|
||||
|
||||
// No existing tasks for ticket
|
||||
|
||||
// We'll reuse production controllers but point them at our fake client
|
||||
// by creating minimal adapter wrappers. For this test we only need to
|
||||
// exercise selection+assignment via TasksController.createTask.
|
||||
|
||||
// Create TicketsController-like flow manually (simulate promoted path):
|
||||
// 1) create task row (simulate TasksController.createTask)
|
||||
final taskPayload = {
|
||||
'title': 'Printer down',
|
||||
'description': 'Paper jam',
|
||||
'office_id': 'office-1',
|
||||
'ticket_id': 'TCK-1',
|
||||
};
|
||||
// emulate insert -> returns row with id
|
||||
final insertedTask = await fake
|
||||
.from('tasks')
|
||||
.insert(taskPayload)
|
||||
.single();
|
||||
final taskId = insertedTask['id'] as String;
|
||||
|
||||
// 2) run simplified autoAssign logic (mirror of production selection)
|
||||
// Gather team ids covering office
|
||||
final teams = fake.tables['teams']!;
|
||||
final teamIds = teams
|
||||
.where((t) => (t['office_ids'] as List).contains('office-1'))
|
||||
.map((t) => t['id'] as String)
|
||||
.toList();
|
||||
|
||||
final memberRows = fake.tables['team_members']!
|
||||
.where((m) => teamIds.contains(m['team_id']))
|
||||
.toList();
|
||||
final candidateIds = memberRows
|
||||
.map((r) => r['user_id'] as String)
|
||||
.toList();
|
||||
|
||||
// read duty_schedules for candidates
|
||||
final onDuty = <String, DateTime>{};
|
||||
for (final s in fake.tables['duty_schedules']!) {
|
||||
final uid = s['user_id'] as String;
|
||||
if (!candidateIds.contains(uid)) continue;
|
||||
final checkIn = DateTime.parse(s['check_in_at'] as String);
|
||||
onDuty[uid] = checkIn;
|
||||
}
|
||||
|
||||
// compute completedToday as 0 (no completed tasks in fake DB)
|
||||
final candidates = onDuty.entries
|
||||
.map(
|
||||
(e) => AutoAssignCandidate(
|
||||
userId: e.key,
|
||||
checkInAt: e.value,
|
||||
completedToday: 0,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final chosen = chooseAutoAssignCandidate(candidates);
|
||||
expect(chosen, equals('u2'), reason: 'Late-comer u2 should be chosen');
|
||||
|
||||
// Insert assignment row (what _autoAssignTask would do)
|
||||
await fake.from('task_assignments').insert({
|
||||
'task_id': taskId,
|
||||
'user_id': chosen,
|
||||
}).single();
|
||||
|
||||
// Assert task exists and assignment was created
|
||||
final createdTask = fake.tables['tasks']!.firstWhere(
|
||||
(t) => t['id'] == taskId,
|
||||
);
|
||||
expect(createdTask['ticket_id'], equals('TCK-1'));
|
||||
|
||||
final assignment = fake.tables['task_assignments']!.firstWhere(
|
||||
(a) => a['task_id'] == taskId,
|
||||
orElse: () => {},
|
||||
);
|
||||
expect(assignment['user_id'], equals('u2'));
|
||||
},
|
||||
timeout: Timeout(Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user