yx_async_throttle_flutter/test/async_throttle_safety_test....

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);
});
});
}