Compare commits

..

No commits in common. "master" and "1.0.0" have entirely different histories.

5 changed files with 27 additions and 2329 deletions

418
README.md
View File

@ -1,407 +1,39 @@
# yx_async_throttle_flutter <!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
一个健壮的 Flutter 异步任务防抖Debounce与节流Throttle工具类。 For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
它完美结合了 **时间策略**Throttle/Debounce**异步任务锁**Async Task Locking旨在解决复杂场景下如弱网环境或用户快速连点的重复执行问题。 For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/to/develop-packages).
-->
## 功能特性 TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
- **双重保护机制**: ## Features
- **时间策略**: 防止短时间内快速重复点击(基于 easy_debounce
- **异步锁**: 即使时间间隔已过,如果上一个异步任务(如网络请求)尚未完成,也会阻止新任务执行。
- **节流模式 (Throttle - 默认)**: 立即执行,并在指定时间内忽略后续调用。非常适合“提交”、“登录”等按钮。
- **防抖模式 (Debounce)**: 延迟执行,直到停止调用一段时间后才执行。非常适合“搜索框”、“实时输入校验”。
- **内存安全**: 在防抖模式下正确管理 `Completer`,防止因任务被取消导致的 `await` 永久挂起和内存泄漏。
- **简单易用**: 单例调用API 简洁。
## 快速开始 (Getting Started) TODO: List what your package can do. Maybe include images, gifs, or videos.
在你的 `pubspec.yaml` 文件中添加依赖: ## Getting started
```yaml TODO: List prerequisites and provide or point to information on how to
dependencies: start using the package.
yx_async_throttle_flutter:
git:
url: https://gitea.23544.com/wangyang/yx_async_throttle_flutter.git
ref: 1.0.0
```
## 使用指南 (Usage) ## Usage
### 1. 基础节流 (按钮防暴力点击) TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
适用于“提交订单”、“登录”、“保存”等场景。
即使网络很慢(请求耗时 > 500ms或者用户手速很快该函数也能确保在上一个请求完成**且**时间间隔满足之前,不会重复执行。
```dart ```dart
import 'package:yx_async_throttle_flutter/async_throttle.dart'; const like = 'sample';
void onSubmit() async {
// 使用唯一的 tagId 标识这个动作
await AsyncThrottle.instance.execute(
'submit_order_btn',
() async {
// 你的异步逻辑(例如 API 调用)
print('正在提交订单...');
await Future.delayed(Duration(seconds: 2)); // 模拟耗时网络请求
print('订单提交完成!');
},
duration: Duration(milliseconds: 500), // 默认 300ms
enableDebounce: false, // 默认 false (节流模式)
);
}
``` ```
### 1.1 UI 回调最佳实践(不影响 UI / 不抛到全局) ## Additional information
`onTap` / `onPressed` / `PopupMenuItem.onTap` 这类回调通常**不能 await**(即使写成 async 也经常没人等待它)。 TODO: Tell users more about the package: where to find more information, how to
为了避免 `onExecute` 内部异常变成 **unhandled async error** 影响 UI建议用 `executeSafe` contribute to the package, how to file issues, what response they can expect
from the package authors, and more.
```dart
PopupMenuItem(
onTap: () {
AsyncThrottle.instance.executeSafe(
'withdraw_task',
controller.withdrawTask,
onError: (e, s) {
// 这里做日志/Toast/上报,不影响 UI
},
);
},
child: ...,
)
```
### 2. 防抖模式 (搜索或输入)
适用于搜索框、实时输入联想等场景。只有当用户停止输入指定时间后,才会触发逻辑。
```dart
import 'package:yx_async_throttle_flutter/async_throttle.dart';
void onSearchChanged(String query) async {
await AsyncThrottle.instance.execute(
'search_input',
() async {
// 只有当用户停止输入 500ms 后才会执行
print('搜索内容: $query');
await fetchSearchResults(query);
},
duration: Duration(milliseconds: 500),
enableDebounce: true, // 启用防抖模式
);
}
```
### 3. 检查执行状态
你可以随时检查某个任务是否正在执行中:
```dart
// 检查指定 tagId 是否有任务正在执行
bool isRunning = AsyncThrottle.instance.isExecuting('submit_order_btn');
if (isRunning) {
print('订单正在提交中,请稍候...');
}
```
### 4. 清除锁 (可选)
在极少数情况下(例如用户退出登录时),你可能需要强制清除所有内部锁。
```dart
AsyncThrottle.instance.clearAllLocks();
```
> ⚠️ **注意**: `clearAllLocks()` 仅清除异步锁状态,不会影响 EasyThrottle/EasyDebounce 的内部时间窗口。
---
## API 参考
### `execute` 方法
```dart
Future<void> execute(
String tagId,
Future<void> Function() onExecute, {
Duration duration = const Duration(milliseconds: 300),
bool enableDebounce = false,
})
```
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `tagId` | `String` | 必填 | 任务的唯一标识符 |
| `onExecute` | `Future<void> Function()` | 必填 | 要执行的异步函数 |
| `duration` | `Duration` | `300ms` | 节流/防抖的时间间隔 |
| `enableDebounce` | `bool` | `false` | `false`=节流模式,`true`=防抖模式 |
### `executeSafe` 方法
```dart
Future<void> executeSafe(
String tagId,
Future<void> Function() onExecute, {
Duration duration = const Duration(milliseconds: 300),
bool enableDebounce = false,
void Function(Object error, StackTrace stackTrace)? onError,
})
```
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `tagId` | `String` | 必填 | 任务的唯一标识符 |
| `onExecute` | `Future<void> Function()` | 必填 | 要执行的异步函数 |
| `duration` | `Duration` | `300ms` | 节流/防抖的时间间隔 |
| `enableDebounce` | `bool` | `false` | `false`=节流模式,`true`=防抖模式 |
| `onError` | `Function?` | `null` | 错误回调,用于日志/上报 |
### `isExecuting` 方法
```dart
bool isExecuting(String tagId)
```
检查指定 `tagId` 的任务是否正在执行中。
### `clearAllLocks` 方法
```dart
void clearAllLocks()
```
强制清除所有异步锁和等待中的 Completer。
---
## 核心优势:为什么需要异步锁?
普通的 `Throttle` 库通常只根据 **时间** 来控制执行。
**典型 Bug 场景:**
设定节流时间为 `500ms`
1. 用户点击“提交”。请求发出,但网络很慢,耗时 `2000ms`
2. 在 `600ms` 时(此时节流时间已过),用户因焦急再次点击“提交”。
3. **问题**: 普通 Throttle 库会认为 500ms 已到,允许执行第二次请求,导致 **重复下单**
**本库的解决方案:**
`yx_async_throttle_flutter` 会检查 `isExecuting` 状态。虽然时间已过但因为第一个请求2000ms还在运行第二次点击会被直接拦截从而完美防止重复提交。
> **💡 最佳实践**: 如果在 `onExecute` 函数内部再添加 **Loading 蒙层**UI 阻断),配合本插件即可实现 **“三层防护”**UI 蒙层 + 时间策略 + 异步锁),彻底杜绝任何异常场景下的重复操作。
---
## 更多使用示例
### 表单多按钮场景
当页面有多个按钮时,使用不同的 `tagId` 确保它们互不影响:
```dart
class OrderPage extends StatelessWidget {
void onSave() {
AsyncThrottle.instance.executeSafe('order_save', () async {
await saveOrder();
});
}
void onSubmit() {
AsyncThrottle.instance.executeSafe('order_submit', () async {
await submitOrder();
});
}
void onCancel() {
AsyncThrottle.instance.executeSafe('order_cancel', () async {
await cancelOrder();
});
}
}
```
### 配合 GetX/Provider 状态管理
```dart
class OrderController extends GetxController {
final isLoading = false.obs;
Future<void> submitOrder() async {
await AsyncThrottle.instance.execute(
'submit_order',
() async {
isLoading.value = true;
try {
await api.submitOrder();
Get.snackbar('成功', '订单提交成功');
} finally {
isLoading.value = false;
}
},
);
}
}
```
### 列表项点击防重
```dart
ListView.builder(
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
onTap: () {
// 使用 item.id 作为唯一标识,确保不同列表项互不影响
AsyncThrottle.instance.executeSafe(
'list_item_${item.id}',
() async {
await navigateToDetail(item);
},
);
},
);
},
)
```
### 实时输入验证(防抖)
```dart
TextField(
onChanged: (value) {
AsyncThrottle.instance.execute(
'username_validation',
() async {
final isAvailable = await api.checkUsername(value);
setState(() {
usernameError = isAvailable ? null : '用户名已被占用';
});
},
enableDebounce: true,
duration: const Duration(milliseconds: 500),
);
},
)
```
---
## 注意事项与最佳实践
### ✅ 推荐做法
1. **使用有意义的 tagId**
```dart
// ✅ 好
'submit_order_page_main'
'search_product_list'
// ❌ 不好
'btn1'
'click'
```
2. **在 onTap/onPressed 中使用 executeSafe**
```dart
// ✅ 推荐:不会影响 UI
onTap: () {
AsyncThrottle.instance.executeSafe('action', doSomething);
}
// ⚠️ 需要注意:异常可能变成 unhandled async error
onTap: () async {
await AsyncThrottle.instance.execute('action', doSomething);
}
```
3. **合理设置 duration**
- 按钮点击:`300ms ~ 500ms`
- 搜索防抖:`300ms ~ 800ms`
- 实时验证:`500ms ~ 1000ms`
### ⚠️ 注意事项
1. **tagId 全局唯一**:不同功能使用不同的 tagId避免意外阻塞
2. **单例模式**`AsyncThrottle.instance` 是全局单例,状态跨页面共享
3. **页面销毁时的处理**:如果需要在页面销毁时取消未完成的任务,可以在 `dispose` 中调用:
```dart
@override
void dispose() {
// 可选:清除该页面相关的锁
// 注意:这会清除所有锁,请谨慎使用
// AsyncThrottle.instance.clearAllLocks();
super.dispose();
}
```
4. **异常处理**`execute` 会传播异常,`executeSafe` 会吞掉异常并调用 `onError`
---
## 与其他方案的对比
| 特性 | yx_async_throttle | 普通 Throttle | 手动锁变量 |
|------|-------------------|---------------|-----------|
| 时间策略 | ✅ | ✅ | ❌ |
| 异步锁 | ✅ | ❌ | ✅ |
| 弱网保护 | ✅ | ❌ | ✅ |
| 内存安全 | ✅ | ✅ | ⚠️ 需自行处理 |
| 防抖模式 | ✅ | ✅ | ❌ |
| 代码简洁 | ✅ | ✅ | ❌ |
---
## 测试覆盖
本库已通过 **75 项** 全面的单元测试,覆盖:
- ✅ 单例模式
- ✅ 节流/防抖核心功能
- ✅ 异常处理与传播
- ✅ 并发场景100+ 次快速调用)
- ✅ 内存安全(无泄漏)
- ✅ 重入防护(无死锁)
- ✅ 边界条件(极端参数)
- ✅ 真实业务场景模拟
详细测试报告请参阅 [TEST_REPORT.md](./TEST_REPORT.md)。
---
## 常见问题 (FAQ)
### Q: 为什么我的第二次点击没有执行?
A: 这正是本库的设计目的。检查以下两点:
1. 是否在 `duration` 时间窗口内?
2. 上一次任务是否还在执行中?(使用 `isExecuting` 检查)
### Q: 如何让同一按钮在不同页面独立工作?
A: 使用包含页面标识的 tagId
```dart
'submit_order_${widget.pageId}'
```
### Q: executeSafe 和 execute 有什么区别?
A:
- `execute`: 异常会向外抛出,适合可以 await 的场景
- `executeSafe`: 异常被内部捕获,通过 onError 回调处理,适合 onTap 等无法 await 的场景
### Q: clearAllLocks 后为什么还是不能立即执行?
A: `clearAllLocks` 只清除异步锁EasyThrottle 的时间窗口仍然有效。需要等待时间窗口过期,或使用不同的 tagId。
---
## 其他信息
本库底层的时间策略依赖于 [easy_debounce](https://pub.dev/packages/easy_debounce)。
## License
MIT License

View File

@ -1,211 +0,0 @@
# YX Async Throttle Flutter 测试报告
## 📋 测试概要
| 项目 | 详情 |
|------|------|
| **测试日期** | 2025-12-12 |
| **测试框架** | flutter_test |
| **总测试用例** | 75 |
| **通过** | 75 ✅ |
| **失败** | 0 |
| **通过率** | 100% |
---
## 🎯 测试覆盖范围
### 1. 单例模式测试 (Singleton Pattern Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 多次获取实例应返回同一对象 | ✅ | 验证单例模式正确实现 |
| 实例不为null | ✅ | 确保实例可用性 |
| 实例类型正确 | ✅ | 类型安全检查 |
### 2. 节流模式核心功能测试 (Throttle Mode - Core Functionality)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 首次调用应立即执行 | ✅ | 验证节流模式立即执行特性 |
| duration内的后续调用应被忽略 | ✅ | 验证节流时间窗口内的调用拦截 |
| duration过后应可以再次执行 | ✅ | 验证节流时间窗口重置 |
| 异步任务执行中新调用应被忽略(锁机制) | ✅ | **核心功能**: 弱网场景下的重复请求拦截 |
| 异常后锁应正确释放 | ✅ | 异常安全性验证 |
| 异常应正确传播 | ✅ | 错误处理正确性 |
| 不同tagId应互不影响 | ✅ | 隔离性验证 |
| 节流期间多次调用全部被忽略 | ✅ | 多次快速点击场景 |
### 3. 防抖模式核心功能测试 (Debounce Mode - Core Functionality)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 单次调用应延迟执行 | ✅ | 验证防抖延迟执行特性 |
| 快速连续调用只应执行最后一次 | ✅ | **核心功能**: 搜索场景优化 |
| 被取消调用的Future应正确完成不挂起 | ✅ | **关键**: 内存泄漏防护 |
| 任务执行中新调用应被阻止 | ✅ | 锁机制验证 |
| 防抖异常应正确传播到最后一个调用者 | ✅ | 错误传播正确性 |
| 防抖异常后锁应正确释放 | ✅ | 异常安全性 |
### 4. executeSafe 安全模式测试 (UI Safe Mode)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 节流模式异常不应向外抛 | ✅ | UI安全保护 |
| 防抖模式异常不应向外抛 | ✅ | UI安全保护 |
| onError回调正确接收错误和堆栈 | ✅ | 错误收集功能 |
| 无异常时onError不应被调用 | ✅ | 正常流程验证 |
| onError为null时异常被静默吞掉 | ✅ | 容错处理 |
### 5. isExecuting 状态检查测试
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 执行前应返回false | ✅ | 初始状态 |
| 节流执行中应返回true | ✅ | 执行中状态 |
| 防抖执行中应返回true | ✅ | 执行中状态 |
| 异常后应返回false | ✅ | 异常后状态恢复 |
| 不存在的tagId应返回false | ✅ | 边界条件 |
### 6. clearAllLocks 测试
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 应清除所有锁 | ✅ | 批量清除功能 |
| 应释放等待中的防抖Completer | ✅ | **关键**: 防止Future挂起 |
| 清除后异步锁应解除 | ✅ | 状态重置 |
| 多次调用clearAllLocks应安全 | ✅ | 幂等性验证 |
### 7. 并发场景测试 (Concurrency Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 多个不同tagId并发执行 | ✅ | 并行任务隔离 |
| 大量快速调用压力测试 - 节流 | ✅ | 100次快速调用 |
| 大量快速调用压力测试 - 防抖 | ✅ | 100次快速调用 |
| 混合模式并发 | ✅ | 节流+防抖混合场景 |
### 8. 边界条件测试 (Edge Cases)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| duration为零应正常工作 | ✅ | 边界值 |
| 极短duration应正常工作 | ✅ | 微秒级duration |
| 空字符串tagId应正常工作 | ✅ | 空字符串处理 |
| 特殊字符tagId应正常工作 | ✅ | 含中文、emoji、特殊符号 |
| 长字符串tagId应正常工作 | ✅ | 10000字符长度 |
| 同步完成的异步任务应正常工作 | ✅ | 同步async函数 |
| 嵌套异步任务应正常工作 | ✅ | 多层await |
### 9. 重入测试 (Reentrancy Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 在回调中调用同一tagId应被阻止无死锁 | ✅ | **关键**: 死锁防护 |
| 在回调中调用不同tagId应正常执行 | ✅ | 嵌套调用支持 |
| 防抖模式重入应安全 | ✅ | 防抖重入保护 |
### 10. 内存安全测试 (Memory Safety Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 大量防抖调用后Completer应正确清理 | ✅ | 200*5次调用测试 |
| 异常场景下资源应正确释放 | ✅ | 50次异常场景 |
| clearAllLocks后状态应完全重置 | ✅ | 完整状态重置 |
### 11. 时间精度测试 (Timing Precision Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 节流duration应基本准确 | ✅ | 时间控制精度 |
| 防抖duration应基本准确 | ✅ | 延迟执行精度 |
### 12. 异常类型测试 (Exception Type Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| Error类型应正确传播 | ✅ | AssertionError |
| String异常应正确传播 | ✅ | throw String |
| 自定义异常应正确传播 | ✅ | CustomTestException |
| 防抖模式异常类型应正确传播 | ✅ | RangeError |
### 13. 默认参数测试 (Default Parameters Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 默认duration应为300ms | ✅ | API文档一致性 |
| 默认enableDebounce应为false | ✅ | 节流为默认模式 |
### 14. 顺序保证测试 (Order Guarantee Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 防抖模式应保证只执行最后一次 | ✅ | 10次调用只执行第10次 |
### 15. 复杂场景集成测试 (Integration Tests)
| 用例名称 | 状态 | 描述 |
|---------|------|------|
| 模拟真实按钮快速点击场景 | ✅ | **实战场景** |
| 模拟搜索输入防抖场景 | ✅ | **实战场景** |
| 模拟弱网环境重复点击场景 | ✅ | **实战场景** |
| 混合使用节流和防抖应互不干扰 | ✅ | 模式切换 |
---
## 🔍 测试文件统计
| 测试文件 | 测试组数 | 测试用例数 |
|---------|---------|-----------|
| `async_throttle_comprehensive_test.dart` | 15 | 62 |
| `async_throttle_safety_test.dart` | 1 | 12 |
| `yx_async_throttle_flutter_test.dart` | 1 | 1 |
| **总计** | **17** | **75** |
---
## ✅ 商用级质量评估
### 功能完整性
- ✅ 节流模式 (Throttle) 完整实现
- ✅ 防抖模式 (Debounce) 完整实现
- ✅ 异步锁机制正确工作
- ✅ UI安全模式 (executeSafe) 可用
### 稳定性
- ✅ 无死锁风险
- ✅ 无内存泄漏Completer正确清理
- ✅ 异常场景下资源正确释放
- ✅ 边界条件处理完善
### 性能
- ✅ 100次快速调用压力测试通过
- ✅ 1000次防抖调用内存安全测试通过
- ✅ 并发场景正确隔离
### 异常处理
- ✅ 异常正确传播
- ✅ 各类异常类型支持Error、Exception、String、自定义异常
- ✅ UI安全模式正确吞掉异常
- ✅ onError回调正确接收错误信息和堆栈
### 兼容性
- ✅ 特殊字符tagId支持
- ✅ 中文/emoji tagId支持
- ✅ 空字符串tagId支持
- ✅ 极端duration值支持
---
## 📊 结论
**✅ 该插件已通过全部 75 项测试,达到商用级质量标准。**
### 核心优势
1. **弱网场景保护**: 通过异步锁机制,有效防止弱网环境下的重复请求
2. **双模式支持**: 同时支持节流(适合按钮点击)和防抖(适合搜索输入)
3. **内存安全**: 被取消的防抖调用不会导致Future挂起
4. **UI友好**: executeSafe方法可安全用于onTap等无法await的场景
5. **异常安全**: 任何异常都会正确释放锁,不会导致后续调用被永久阻塞
### 建议使用场景
- 按钮防重复点击
- 搜索框输入防抖
- 表单提交防重复
- API请求去重
- 弱网环境用户操作保护
### 注意事项
1. `clearAllLocks()` 仅清除异步锁,不影响 EasyThrottle/EasyDebounce 的内部时间窗口
2. 同一tagId切换节流/防抖模式时,建议等待上一次操作完成
3. 单例模式全局共享状态注意tagId的唯一性
---
*测试报告由自动化测试生成*

View File

@ -69,10 +69,6 @@ class AsyncThrottle {
_asyncLocks[tagId] = true; _asyncLocks[tagId] = true;
try { try {
await onExecute(); await onExecute();
} catch (e, stack) {
// execute() Future
// Timer unhandled async error UI
_completeDebounce(tagId, error: e, stackTrace: stack);
} finally { } finally {
_asyncLocks.remove(tagId); _asyncLocks.remove(tagId);
_completeDebounce(tagId); // await _completeDebounce(tagId); // await
@ -99,36 +95,13 @@ class AsyncThrottle {
} }
} }
/// UI onTap/onPressed await
///
/// - UI /
/// - [onError] //Toast
Future<void> executeSafe(
String tagId,
Future<void> Function() onExecute, {
Duration duration = const Duration(milliseconds: 300),
bool enableDebounce = false,
void Function(Object error, StackTrace stackTrace)? onError,
}) {
return execute(tagId, onExecute, duration: duration, enableDebounce: enableDebounce).catchError((
Object e,
StackTrace s,
) {
onError?.call(e, s);
});
}
/// Completer /// Completer
void _completeDebounce(String tagId, {Object? error, StackTrace? stackTrace}) { void _completeDebounce(String tagId) {
if (_debounceCompleters.containsKey(tagId)) { if (_debounceCompleters.containsKey(tagId)) {
final completer = _debounceCompleters[tagId]; final completer = _debounceCompleters[tagId];
if (completer != null && !completer.isCompleted) { if (completer != null && !completer.isCompleted) {
if (error != null) {
completer.completeError(error, stackTrace);
} else {
completer.complete(); completer.complete();
} }
}
_debounceCompleters.remove(tagId); _debounceCompleters.remove(tagId);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,370 +0,0 @@
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);
});
});
}