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(); // 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 = []; // 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 = >[]; 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(); 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().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()); }, ); 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()); }, ); await Future.delayed(const Duration(milliseconds: 120)); await f.timeout(const Duration(seconds: 1)); expect(errorCalled, isTrue); expect(throttle.isExecuting(tag), isFalse); }); }); }