Basic framework
This commit is contained in:
413
finlog_app/fluttery/test/system/worker/worker_impl_test.dart
Normal file
413
finlog_app/fluttery/test/system/worker/worker_impl_test.dart
Normal file
@@ -0,0 +1,413 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fluttery/fluttery.dart';
|
||||
import 'package:fluttery/logger.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:fluttery/src/system/worker/worker_impl.dart';
|
||||
import 'package:fluttery/worker.dart';
|
||||
|
||||
import '../../mocks/mocks.dart';
|
||||
|
||||
Future<void> pumpMicro([int times = 10]) => pumpEventQueue(times: times);
|
||||
|
||||
Future<void> waitFor(
|
||||
bool Function() predicate, {
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
Duration step = const Duration(milliseconds: 20),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (predicate()) return;
|
||||
await Future<void>.delayed(step);
|
||||
}
|
||||
fail('Condition not met within $timeout');
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
expect(ServicesBinding.rootIsolateToken, isNotNull);
|
||||
|
||||
App.registerService<Logger>(() => MockUtils.mockLogger());
|
||||
});
|
||||
|
||||
group('WorkerImpl', () {
|
||||
late WorkerImpl worker;
|
||||
|
||||
setUp(() {
|
||||
worker = WorkerImpl();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('spawn returns value; preTask runs; active->history tracking', () async {
|
||||
// We'll verify preTask runs by checking the task itself can access
|
||||
// what the preTask sets up (SharedPreferences mock)
|
||||
final future = worker.spawn<int>(
|
||||
'successful_task',
|
||||
() async {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
// This would fail if preTask didn't run to set up SharedPreferences mock
|
||||
SharedPreferences.setMockInitialValues({'test': 'verified'});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('preTaskRan', 'true');
|
||||
|
||||
return 42;
|
||||
},
|
||||
preTask: () {
|
||||
// Set up the SharedPreferences mock so the task can use it
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
},
|
||||
);
|
||||
|
||||
// Verify worker is registered as active shortly after spawn
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
expect(worker.getActiveWorkers().length, 1);
|
||||
|
||||
final activeWorkers = worker.getActiveWorkers();
|
||||
expect(activeWorkers.first.name, 'successful_task');
|
||||
expect(activeWorkers.first.status, WorkerStatus.running);
|
||||
|
||||
// Wait for completion
|
||||
final result = await future;
|
||||
expect(result, 42);
|
||||
|
||||
// The fact that the task completed successfully without throwing an exception
|
||||
// when trying to use SharedPreferences proves that preTask ran
|
||||
|
||||
// Wait for the completion handlers to run and move worker to history
|
||||
await waitFor(() => worker.getActiveWorkers().isEmpty);
|
||||
|
||||
// Verify the worker was moved to history with completed status
|
||||
final historyWorkers = worker.getAllWorkers();
|
||||
expect(historyWorkers.length, 1);
|
||||
expect(historyWorkers.first.status, WorkerStatus.completed);
|
||||
expect(historyWorkers.first.name, 'successful_task');
|
||||
expect(historyWorkers.first.endedAt, isNotNull);
|
||||
});
|
||||
test('timeout marks job as timedOut and throws TimeoutException', () async {
|
||||
final timedWorker = WorkerImpl(
|
||||
defaultTimeout: const Duration(milliseconds: 50),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
timedWorker.spawn<void>(
|
||||
'timeout_task',
|
||||
() async => Future.delayed(const Duration(milliseconds: 200)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
),
|
||||
throwsA(isA<TimeoutException>()),
|
||||
);
|
||||
|
||||
// Wait for worker to update history
|
||||
await waitFor(() => timedWorker.getAllWorkers().isNotEmpty);
|
||||
|
||||
final allWorkers = timedWorker.getAllWorkers();
|
||||
expect(allWorkers.first.status, WorkerStatus.timedOut);
|
||||
expect(allWorkers.first.name, 'timeout_task');
|
||||
});
|
||||
|
||||
test('custom timeout overrides default timeout', () async {
|
||||
final timedWorker = WorkerImpl(
|
||||
defaultTimeout: const Duration(milliseconds: 200), // Long default
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
timedWorker.spawn<void>(
|
||||
'custom_timeout_task',
|
||||
() async => Future.delayed(const Duration(milliseconds: 100)),
|
||||
timeout: const Duration(milliseconds: 50), // Short custom timeout
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
),
|
||||
throwsA(isA<TimeoutException>()),
|
||||
);
|
||||
|
||||
await waitFor(() => timedWorker.getAllWorkers().isNotEmpty);
|
||||
expect(timedWorker.getAllWorkers().first.status, WorkerStatus.timedOut);
|
||||
});
|
||||
|
||||
test('failure marks job as failed and surfaces exception', () async {
|
||||
// Create a variable to capture the actual exception
|
||||
Object? caughtException;
|
||||
|
||||
try {
|
||||
await worker.spawn<void>('failing_task', () async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
throw StateError('intentional failure');
|
||||
}, preTask: () => SharedPreferences.setMockInitialValues({}));
|
||||
fail('Expected an exception to be thrown');
|
||||
} catch (e) {
|
||||
caughtException = e;
|
||||
}
|
||||
|
||||
// Verify that an exception was thrown (could be RemoteError or the original StateError)
|
||||
expect(caughtException, isNotNull);
|
||||
expect(
|
||||
caughtException is RemoteError || caughtException is StateError,
|
||||
isTrue,
|
||||
reason:
|
||||
'Should throw either RemoteError or StateError, got: ${caughtException.runtimeType}',
|
||||
);
|
||||
|
||||
// Wait for worker to update history
|
||||
await waitFor(() => worker.getAllWorkers().isNotEmpty);
|
||||
|
||||
final allWorkers = worker.getAllWorkers();
|
||||
expect(allWorkers.first.status, WorkerStatus.failed);
|
||||
expect(allWorkers.first.name, 'failing_task');
|
||||
expect(allWorkers.first.error, isNotNull);
|
||||
});
|
||||
|
||||
test('getWorker finds active and completed workers by ID', () async {
|
||||
final future = worker.spawn<int>('trackable_task', () async {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
return 123;
|
||||
}, preTask: () => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
// Find worker while active
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
final activeWorkers = worker.getActiveWorkers();
|
||||
expect(activeWorkers.length, 1);
|
||||
|
||||
final workerId = activeWorkers.first.id;
|
||||
final activeWorker = worker.getWorker(workerId);
|
||||
expect(activeWorker, isNotNull);
|
||||
expect(activeWorker!.status, WorkerStatus.running);
|
||||
|
||||
// Wait for completion
|
||||
await future;
|
||||
await waitFor(() => worker.getActiveWorkers().isEmpty);
|
||||
|
||||
// Find worker in history
|
||||
final completedWorker = worker.getWorker(workerId);
|
||||
expect(completedWorker, isNotNull);
|
||||
expect(completedWorker!.status, WorkerStatus.completed);
|
||||
});
|
||||
|
||||
test('getWorker returns null for non-existent ID', () {
|
||||
expect(worker.getWorker('non-existent'), isNull);
|
||||
expect(worker.getWorker('999999'), isNull);
|
||||
});
|
||||
|
||||
test('worker ID generation uses timestamp format', () async {
|
||||
final futures = <Future>[];
|
||||
final Set<String> generatedIds = <String>{};
|
||||
|
||||
// Spawn multiple workers with sufficient delay to ensure unique timestamps
|
||||
for (int i = 0; i < 3; i++) {
|
||||
futures.add(
|
||||
worker.spawn<void>(
|
||||
'task_$i',
|
||||
() async => Future.delayed(const Duration(milliseconds: 10)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
),
|
||||
);
|
||||
// Ensure sufficient delay for different timestamps
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1));
|
||||
}
|
||||
|
||||
// Wait for all workers to be registered as active
|
||||
await waitFor(() => worker.getActiveWorkers().length == 3);
|
||||
|
||||
final activeWorkers = worker.getActiveWorkers();
|
||||
expect(activeWorkers.length, 3);
|
||||
|
||||
// Verify each ID follows the timestamp format and is unique
|
||||
for (final workerInfo in activeWorkers) {
|
||||
expect(workerInfo.id, startsWith('iso-'));
|
||||
expect(
|
||||
generatedIds.contains(workerInfo.id),
|
||||
isFalse,
|
||||
reason: 'Worker ID should be unique: ${workerInfo.id}',
|
||||
);
|
||||
generatedIds.add(workerInfo.id);
|
||||
|
||||
final timestampPart = workerInfo.id.substring(
|
||||
4,
|
||||
); // Remove 'iso-' prefix
|
||||
final timestamp = int.tryParse(timestampPart);
|
||||
expect(
|
||||
timestamp,
|
||||
isNotNull,
|
||||
reason: 'Timestamp part should be a valid integer: $timestampPart',
|
||||
);
|
||||
expect(
|
||||
timestamp,
|
||||
greaterThan(0),
|
||||
reason: 'Timestamp should be positive: $timestamp',
|
||||
);
|
||||
|
||||
// Verify timestamp is reasonable (not too old, not in future)
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
expect(
|
||||
timestamp,
|
||||
lessThanOrEqualTo(now),
|
||||
reason: 'Timestamp should not be in the future',
|
||||
);
|
||||
expect(
|
||||
timestamp,
|
||||
greaterThan(now - 10000),
|
||||
reason: 'Timestamp should be recent (within 10 seconds)',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify we generated 3 unique IDs
|
||||
expect(generatedIds.length, 3);
|
||||
|
||||
await Future.wait(futures);
|
||||
});
|
||||
|
||||
test('getAllWorkers combines active and history workers', () async {
|
||||
// Spawn and complete a short task first
|
||||
await worker.spawn<void>(
|
||||
'short_task',
|
||||
() async => Future.delayed(const Duration(milliseconds: 10)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
);
|
||||
|
||||
// Wait for short task to complete and move to history
|
||||
await waitFor(() => worker.getAllWorkers().length == 1);
|
||||
await waitFor(() => worker.getActiveWorkers().isEmpty);
|
||||
|
||||
// Verify we have one completed worker in history
|
||||
expect(worker.getAllWorkers().length, 1);
|
||||
expect(worker.getAllWorkers().first.status, WorkerStatus.completed);
|
||||
expect(worker.getAllWorkers().first.name, 'short_task');
|
||||
|
||||
// Now spawn a long-running task
|
||||
final longTask = worker.spawn<void>(
|
||||
'long_task',
|
||||
() async => Future.delayed(const Duration(milliseconds: 100)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
);
|
||||
|
||||
// Wait briefly for long task to be registered as active
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
// Now we should have 2 workers: 1 active (long_task) and 1 in history (short_task)
|
||||
final allWorkers = worker.getAllWorkers();
|
||||
expect(allWorkers.length, 2);
|
||||
|
||||
final activeCount = allWorkers
|
||||
.where((w) => w.status == WorkerStatus.running)
|
||||
.length;
|
||||
final completedCount = allWorkers
|
||||
.where((w) => w.status == WorkerStatus.completed)
|
||||
.length;
|
||||
|
||||
expect(activeCount, 1);
|
||||
expect(completedCount, 1);
|
||||
|
||||
// Verify the active worker is the long task
|
||||
final activeWorkers = worker.getActiveWorkers();
|
||||
expect(activeWorkers.length, 1);
|
||||
expect(activeWorkers.first.name, 'long_task');
|
||||
|
||||
await longTask;
|
||||
});
|
||||
|
||||
test('purge removes old workers from history', () async {
|
||||
// Complete a task
|
||||
await worker.spawn<void>(
|
||||
'old_task',
|
||||
() async => Future.delayed(const Duration(milliseconds: 10)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
);
|
||||
|
||||
await waitFor(() => worker.getAllWorkers().isNotEmpty);
|
||||
expect(worker.getAllWorkers().length, 1);
|
||||
|
||||
// Purge with zero max age (removes everything)
|
||||
worker.purge(maxAge: Duration.zero);
|
||||
await pumpMicro();
|
||||
|
||||
expect(worker.getAllWorkers(), isEmpty);
|
||||
});
|
||||
|
||||
test('maxHistory limit is enforced', () async {
|
||||
final limitedWorker = WorkerImpl(maxHistory: 2);
|
||||
|
||||
// Complete 3 tasks
|
||||
for (int i = 0; i < 3; i++) {
|
||||
await limitedWorker.spawn<void>(
|
||||
'task_$i',
|
||||
() async => Future.delayed(const Duration(milliseconds: 5)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
);
|
||||
await waitFor(() => limitedWorker.getActiveWorkers().isEmpty);
|
||||
}
|
||||
|
||||
// Should only keep the last 2 in history
|
||||
final allWorkers = limitedWorker.getAllWorkers();
|
||||
expect(allWorkers.length, 2);
|
||||
|
||||
// Should be the most recent tasks (task_1 and task_2)
|
||||
final names = allWorkers.map((w) => w.name).toSet();
|
||||
expect(names.contains('task_1'), isTrue);
|
||||
expect(names.contains('task_2'), isTrue);
|
||||
expect(names.contains('task_0'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'no timeout when defaultTimeout is null and timeout is null',
|
||||
() async {
|
||||
final noTimeoutWorker = WorkerImpl(defaultTimeout: null);
|
||||
|
||||
final result = await noTimeoutWorker.spawn<int>(
|
||||
'no_timeout_task',
|
||||
() async {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
return 999;
|
||||
},
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
);
|
||||
|
||||
expect(result, 999);
|
||||
|
||||
await waitFor(() => noTimeoutWorker.getAllWorkers().isNotEmpty);
|
||||
expect(
|
||||
noTimeoutWorker.getAllWorkers().first.status,
|
||||
WorkerStatus.completed,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('active workers are sorted by start time', () async {
|
||||
final futures = <Future>[];
|
||||
|
||||
// Spawn workers with small delays between them
|
||||
for (int i = 0; i < 3; i++) {
|
||||
futures.add(
|
||||
worker.spawn<void>(
|
||||
'timed_task_$i',
|
||||
() async => Future.delayed(const Duration(milliseconds: 100)),
|
||||
preTask: () => SharedPreferences.setMockInitialValues({}),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
final activeWorkers = worker.getActiveWorkers();
|
||||
expect(activeWorkers.length, 3);
|
||||
|
||||
// Verify they are sorted by start time (earliest first)
|
||||
for (int i = 1; i < activeWorkers.length; i++) {
|
||||
expect(
|
||||
activeWorkers[i - 1].startedAt.isBefore(activeWorkers[i].startedAt) ||
|
||||
activeWorkers[i - 1].startedAt.isAtSameMomentAs(
|
||||
activeWorkers[i].startedAt,
|
||||
),
|
||||
isTrue,
|
||||
reason: 'Workers should be sorted by start time',
|
||||
);
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user