Files
patbond-flutter/test/analytics/analytics_flush_scheduler_test.dart
T
lixi 4d40c38f06
CI / flutter-gates (push) Successful in 2m15s
新增:埋点队列三项完善——30 秒定时冲刷、失败指数退避、anonymousId 持久化(T3-19)
- 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>
2026-09-08 16:12:19 +08:00

210 lines
7.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 第三次失败 → 退避 120st=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=480s240s×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();
});
});
});
}