新增:埋点队列三项完善——30 秒定时冲刷、失败指数退避、anonymousId 持久化(T3-19)
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>
This commit is contained in:
2026-09-08 16:12:19 +08:00
parent 720865bcb9
commit 4d40c38f06
9 changed files with 445 additions and 14 deletions
@@ -0,0 +1,209 @@
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();
});
});
});
}