4d40c38f06
CI / flutter-gates (push) Successful in 2m15s
- 30 秒定时冲刷:AnalyticsService.startPeriodicFlush/stopPeriodicFlush, 前台期间 Timer.periodic 周期冲刷;SessionTracker 新增 onEnterForeground 回调,退后台停(并保留既有 flushNow 触发)、回前台恢复,App dispose 收尾。 - 失败退避:网络错误/5xx 后 30s→60s→120s 指数退避封顶 5 分钟,退避窗口 只挡定时冲刷(flushNow/满 20/冷启动显式触发不受限),上传成功即重置; 429 改按网络错误同路径保段重试(Retry-After 分支待后端限流落地)。 - anonymousId 持久化:restore 时从 shared_preferences 采用/落盘 pb.analytics.anonymousId,首次生成后跨冷启动稳定;读取失败降级 进程内临时 id 不崩溃。 - 测试 272 → 286(+14):fakeAsync 定时/退避 8 个、anonymousId 4 个、 429 保段 1 个、前后台回调成对 1 个;analyze 0 问题、format 无 diff。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
210 lines
7.3 KiB
Dart
210 lines
7.3 KiB
Dart
import 'package:fake_async/fake_async.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:patbond_flutter/analytics/analytics_service.dart';
|
||
|
||
/// 定时冲刷与失败退避测试(M3 T3-19,13 号 §3.4 第 4 触发点 +
|
||
/// iteration-3 06 号 §2.3):fakeAsync 驱动 Timer.periodic,
|
||
/// 时钟注入照 SessionTracker 先例,假上传子类免起真实 HttpServer。
|
||
class _FakeUploadService extends AnalyticsService {
|
||
_FakeUploadService({super.now})
|
||
: super(
|
||
apiBaseUrl: 'http://unused',
|
||
getAccessToken: null,
|
||
getSessionId: () => 'session-x',
|
||
);
|
||
|
||
int uploadAttempts = 0;
|
||
bool failUploads = false;
|
||
final List<int> uploadedBatchSizes = [];
|
||
|
||
@override
|
||
Future<bool> uploadBatch(List<Map<String, dynamic>> events) async {
|
||
uploadAttempts++;
|
||
if (failUploads) {
|
||
throw Exception('simulated network failure');
|
||
}
|
||
uploadedBatchSizes.add(events.length);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
void main() {
|
||
/// 逐秒推进假时间轴并同步注入时钟,保证定时器回调读到的 now 与
|
||
/// 已流逝时间一致(整段 elapse 会让回调读到未更新的旧时钟)。
|
||
(void Function(Duration), _FakeUploadService) setup(FakeAsync async) {
|
||
var clock = DateTime.utc(2026, 9, 8, 10);
|
||
final service = _FakeUploadService(now: () => clock);
|
||
void elapse(Duration duration) {
|
||
final target = clock.add(duration);
|
||
while (clock.isBefore(target)) {
|
||
clock = clock.add(const Duration(seconds: 1));
|
||
async.elapse(const Duration(seconds: 1));
|
||
}
|
||
}
|
||
|
||
return (elapse, service);
|
||
}
|
||
|
||
group('AnalyticsService 定时冲刷', () {
|
||
test('前台每 30 秒冲刷不满 20 条的队列', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.startPeriodicFlush();
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
|
||
elapse(const Duration(seconds: 29));
|
||
expect(service.uploadAttempts, 0);
|
||
|
||
elapse(const Duration(seconds: 1));
|
||
expect(service.uploadedBatchSizes, [1]);
|
||
expect(service.pendingEvents, isEmpty);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
|
||
test('队列为空时定时器不发起上传', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.startPeriodicFlush();
|
||
|
||
elapse(const Duration(minutes: 2));
|
||
expect(service.uploadAttempts, 0);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
|
||
test('stopPeriodicFlush 停止触发(退后台),start 恢复(回前台)', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
|
||
service.startPeriodicFlush();
|
||
service.stopPeriodicFlush();
|
||
elapse(const Duration(minutes: 2));
|
||
expect(service.uploadAttempts, 0);
|
||
|
||
service.startPeriodicFlush();
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadedBatchSizes, [1]);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
|
||
test('startPeriodicFlush 幂等,不叠加多个定时器', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.startPeriodicFlush();
|
||
service.startPeriodicFlush();
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 1);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
});
|
||
|
||
group('AnalyticsService 失败退避', () {
|
||
test('上传失败按 30s→60s→120s 指数退避,期间定时冲刷跳过', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.failUploads = true;
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
service.startPeriodicFlush();
|
||
|
||
// t=30s 首次尝试失败 → 退避 30s(下次可试 t=60s)。
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 1);
|
||
|
||
// t=60s 第二次失败 → 退避 60s(下次 t=120s);t=90s 被跳过。
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 2);
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 2);
|
||
|
||
// t=120s 第三次失败 → 退避 120s;t=150/180/210s 均跳过。
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 3);
|
||
elapse(const Duration(seconds: 90));
|
||
expect(service.uploadAttempts, 3);
|
||
|
||
// t=240s 第四次尝试;事件始终保留在队列。
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 4);
|
||
expect(service.pendingEvents.length, 1);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
|
||
test('退避封顶 5 分钟', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.failUploads = true;
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
service.startPeriodicFlush();
|
||
|
||
// 失败序列 t=30/60/120/240s(退避 30/60/120/240s),
|
||
// 第五次 t=480s:240s×2=480s 超帽,封顶 300s。
|
||
elapse(const Duration(minutes: 8));
|
||
expect(service.uploadAttempts, 5);
|
||
|
||
// t=780s 前(480+300s 窗口内)不再尝试,到点第六次。
|
||
elapse(const Duration(seconds: 299));
|
||
expect(service.uploadAttempts, 5);
|
||
elapse(const Duration(seconds: 1));
|
||
expect(service.uploadAttempts, 6);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
|
||
test('退避只挡定时冲刷,flushNow 显式触发不受限', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.failUploads = true;
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
service.startPeriodicFlush();
|
||
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadAttempts, 1);
|
||
|
||
// 退避窗口内(t=45s)退后台显式冲刷仍然尝试。
|
||
elapse(const Duration(seconds: 15));
|
||
service.flushNow();
|
||
async.flushMicrotasks();
|
||
expect(service.uploadAttempts, 2);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
|
||
test('上传成功即重置退避,恢复 30 秒节奏', () {
|
||
fakeAsync((async) {
|
||
final (elapse, service) = setup(async);
|
||
service.failUploads = true;
|
||
service.trackEvent('page_viewed', {'pageName': 'home'});
|
||
async.flushMicrotasks();
|
||
service.startPeriodicFlush();
|
||
|
||
// t=30/60s 两次失败后网络恢复,t=120s 第三次成功。
|
||
elapse(const Duration(seconds: 60));
|
||
expect(service.uploadAttempts, 2);
|
||
service.failUploads = false;
|
||
elapse(const Duration(seconds: 60));
|
||
expect(service.uploadedBatchSizes, [1]);
|
||
|
||
// 退避已重置:新事件在下一个 30 秒刻度即上传,无残留等待。
|
||
service.trackEvent('page_viewed', {'pageName': 'feed'});
|
||
async.flushMicrotasks();
|
||
elapse(const Duration(seconds: 30));
|
||
expect(service.uploadedBatchSizes, [1, 1]);
|
||
service.stopPeriodicFlush();
|
||
});
|
||
});
|
||
});
|
||
}
|