371 lines
11 KiB
Dart
371 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:yx_async_throttle_flutter/yx_async_throttle_flutter.dart';
|
|
|
|
void main() {
|
|
group('AsyncThrottle Safety Tests', () {
|
|
late AsyncThrottle throttle;
|
|
|
|
setUp(() {
|
|
throttle = AsyncThrottle.instance;
|
|
throttle.clearAllLocks();
|
|
});
|
|
|
|
// --- Throttle Tests ---
|
|
|
|
test('Throttle: Executes immediately and blocks subsequent calls', () async {
|
|
int counter = 0;
|
|
final completer = Completer<void>();
|
|
|
|
// First call: Should execute
|
|
final future1 = throttle.execute('throttle_test', () async {
|
|
counter++;
|
|
await completer.future; // Hold the lock
|
|
}, enableDebounce: false);
|
|
|
|
// Second call: Should be ignored (throttled) immediately
|
|
// Note: execute() is async.
|
|
await throttle.execute('throttle_test', () async {
|
|
counter++;
|
|
}, enableDebounce: false);
|
|
|
|
expect(counter, 1);
|
|
|
|
completer.complete();
|
|
await future1;
|
|
});
|
|
|
|
test('Throttle: Releases lock after exception', () async {
|
|
bool thrown = false;
|
|
const duration = Duration(milliseconds: 50);
|
|
|
|
try {
|
|
await throttle.execute('throttle_error', () async {
|
|
throw Exception('Boom');
|
|
}, duration: duration);
|
|
} catch (e) {
|
|
thrown = true;
|
|
}
|
|
expect(thrown, isTrue);
|
|
|
|
// Wait for throttle duration to pass so EasyThrottle allows next call
|
|
await Future.delayed(duration + const Duration(milliseconds: 20));
|
|
|
|
// Verify lock is released and throttle is reset by time
|
|
int counter = 0;
|
|
await throttle.execute('throttle_error', () async {
|
|
counter++;
|
|
}, duration: duration);
|
|
expect(counter, 1);
|
|
});
|
|
|
|
// --- Debounce Tests ---
|
|
|
|
test('Debounce: Rapid calls - only last executes, previous complete', () async {
|
|
int counter = 0;
|
|
final logs = <String>[];
|
|
|
|
// Call 1
|
|
final f1 = throttle.execute(
|
|
'debounce_rapid',
|
|
() async {
|
|
logs.add('1');
|
|
counter++;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
// Call 2 (Immediate)
|
|
final f2 = throttle.execute(
|
|
'debounce_rapid',
|
|
() async {
|
|
logs.add('2');
|
|
counter++;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
// Wait for debounce duration
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
|
|
await f1;
|
|
await f2;
|
|
|
|
expect(counter, 1);
|
|
expect(logs, ['2']);
|
|
});
|
|
|
|
test('Debounce: Lock released after execution', () async {
|
|
await throttle.execute(
|
|
'debounce_lock',
|
|
() async {
|
|
// do nothing
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 50),
|
|
);
|
|
|
|
// Wait for completion
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
|
|
// Should be able to run again
|
|
int counter = 0;
|
|
await throttle.execute(
|
|
'debounce_lock',
|
|
() async {
|
|
counter++;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 50),
|
|
);
|
|
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
expect(counter, 1);
|
|
});
|
|
|
|
test('Debounce: Exception releases lock and propagates error', () async {
|
|
bool caught = false;
|
|
try {
|
|
await throttle.execute(
|
|
'debounce_error',
|
|
() async {
|
|
throw Exception('Debounce Boom');
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 50),
|
|
);
|
|
} catch (e) {
|
|
caught = true;
|
|
expect(e.toString(), contains('Debounce Boom'));
|
|
}
|
|
expect(caught, isTrue);
|
|
|
|
// Ensure lock is released
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
int counter = 0;
|
|
await throttle.execute(
|
|
'debounce_error',
|
|
() async {
|
|
counter++;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 50),
|
|
);
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|
expect(counter, 1);
|
|
});
|
|
|
|
test('Concurrency: Mixed calls', () async {
|
|
// Run multiple tags
|
|
int c1 = 0;
|
|
int c2 = 0;
|
|
|
|
final f1 = throttle.execute('tag1', () async {
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
c1++;
|
|
}, enableDebounce: false);
|
|
|
|
final f2 = throttle.execute('tag2', () async {
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
c2++;
|
|
}, enableDebounce: false);
|
|
|
|
await Future.wait([f1, f2]);
|
|
expect(c1, 1);
|
|
expect(c2, 1);
|
|
});
|
|
|
|
test('Debounce: Many rapid calls - all returned Futures complete (no hang)', () async {
|
|
const tag = 'debounce_stress_many_calls';
|
|
int executed = 0;
|
|
|
|
final futures = <Future<void>>[];
|
|
for (var i = 0; i < 50; i++) {
|
|
futures.add(
|
|
throttle.execute(
|
|
tag,
|
|
() async {
|
|
executed++;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 30),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Allow the last debounce to fire.
|
|
await Future.delayed(const Duration(milliseconds: 120));
|
|
|
|
// If any earlier call's await hangs, this will time out and fail.
|
|
await Future.wait(futures).timeout(const Duration(seconds: 1));
|
|
expect(executed, 1);
|
|
expect(throttle.isExecuting(tag), isFalse);
|
|
});
|
|
|
|
test('Debounce: clearAllLocks() releases awaiting Future and prevents later execution', () async {
|
|
const tag = 'debounce_clear_pending';
|
|
var executed = false;
|
|
|
|
final f = throttle.execute(
|
|
tag,
|
|
() async {
|
|
executed = true;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 100),
|
|
);
|
|
|
|
// Immediately clear; should release the awaiter and also make the later timer a no-op.
|
|
throttle.clearAllLocks();
|
|
|
|
await f.timeout(const Duration(seconds: 1));
|
|
|
|
// Wait longer than debounce duration to ensure the scheduled callback (if any) would have fired.
|
|
await Future.delayed(const Duration(milliseconds: 200));
|
|
expect(executed, isFalse);
|
|
expect(throttle.isExecuting(tag), isFalse);
|
|
});
|
|
|
|
test('Debounce: clearAllLocks() during execution does not deadlock and completes caller early', () async {
|
|
const tag = 'debounce_clear_while_running';
|
|
final onExecuteHold = Completer<void>();
|
|
var started = false;
|
|
|
|
final f = throttle.execute(
|
|
tag,
|
|
() async {
|
|
started = true;
|
|
await onExecuteHold.future;
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 10),
|
|
);
|
|
|
|
// Ensure debounce has time to trigger and start execution.
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
expect(started, isTrue);
|
|
expect(throttle.isExecuting(tag), isTrue);
|
|
|
|
// This should release the awaiting Future even though the task is still running.
|
|
throttle.clearAllLocks();
|
|
await f.timeout(const Duration(seconds: 1));
|
|
|
|
// Clean up the running onExecute.
|
|
onExecuteHold.complete();
|
|
await Future.delayed(const Duration(milliseconds: 20));
|
|
expect(throttle.isExecuting(tag), isFalse);
|
|
});
|
|
|
|
test('Throttle: Re-entrancy with same tag is blocked, other tag proceeds (no deadlock)', () async {
|
|
const tagA = 'throttle_reentrant_same_tag';
|
|
const tagB = 'throttle_reentrant_other_tag';
|
|
var a = 0;
|
|
var aInner = 0;
|
|
var b = 0;
|
|
|
|
await throttle.execute(tagA, () async {
|
|
a++;
|
|
|
|
// Same tag should be blocked by the async lock and return immediately (no hang).
|
|
await throttle
|
|
.execute(tagA, () async {
|
|
aInner++;
|
|
}, enableDebounce: false)
|
|
.timeout(const Duration(seconds: 1));
|
|
|
|
// Other tag should be allowed to execute.
|
|
await throttle
|
|
.execute(tagB, () async {
|
|
b++;
|
|
}, enableDebounce: false)
|
|
.timeout(const Duration(seconds: 1));
|
|
}, enableDebounce: false);
|
|
|
|
expect(a, 1);
|
|
expect(aInner, 0);
|
|
expect(b, 1);
|
|
expect(throttle.isExecuting(tagA), isFalse);
|
|
expect(throttle.isExecuting(tagB), isFalse);
|
|
});
|
|
|
|
test('Debounce: Last call throws -> last Future errors, previous Futures still complete', () async {
|
|
const tag = 'debounce_last_throws';
|
|
|
|
final f1 = throttle.execute(
|
|
tag,
|
|
() async {
|
|
// no-op
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 50),
|
|
);
|
|
|
|
final f2 = throttle.execute(
|
|
tag,
|
|
() async {
|
|
throw StateError('boom');
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 50),
|
|
);
|
|
|
|
// Attach listeners immediately; otherwise an async error may be reported as "unhandled"
|
|
// if it completes before we start awaiting it.
|
|
final f1Expectation = expectLater(f1, completes);
|
|
final f2Expectation = expectLater(
|
|
f2,
|
|
throwsA(isA<StateError>().having((e) => e.message, 'message', contains('boom'))),
|
|
);
|
|
|
|
await Future.delayed(const Duration(milliseconds: 120));
|
|
|
|
await f1Expectation.timeout(const Duration(seconds: 1));
|
|
await f2Expectation.timeout(const Duration(seconds: 1));
|
|
expect(throttle.isExecuting(tag), isFalse);
|
|
});
|
|
|
|
test('UI-safe mode: Throttle swallow error does not throw (executeSafe)', () async {
|
|
var errorCalled = false;
|
|
await throttle.executeSafe(
|
|
'throttle_swallow_error',
|
|
() async {
|
|
throw StateError('boom');
|
|
},
|
|
enableDebounce: false,
|
|
onError: (e, s) {
|
|
errorCalled = true;
|
|
expect(e, isA<StateError>());
|
|
},
|
|
);
|
|
expect(errorCalled, isTrue);
|
|
expect(throttle.isExecuting('throttle_swallow_error'), isFalse);
|
|
});
|
|
|
|
test('UI-safe mode: Debounce swallow error does not throw (executeSafe)', () async {
|
|
const tag = 'debounce_swallow_error';
|
|
var errorCalled = false;
|
|
|
|
final f = throttle.executeSafe(
|
|
tag,
|
|
() async {
|
|
throw StateError('boom');
|
|
},
|
|
enableDebounce: true,
|
|
duration: const Duration(milliseconds: 30),
|
|
onError: (e, s) {
|
|
errorCalled = true;
|
|
expect(e, isA<StateError>());
|
|
},
|
|
);
|
|
|
|
await Future.delayed(const Duration(milliseconds: 120));
|
|
await f.timeout(const Duration(seconds: 1));
|
|
expect(errorCalled, isTrue);
|
|
expect(throttle.isExecuting(tag), isFalse);
|
|
});
|
|
});
|
|
}
|