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>
105 lines
4.2 KiB
Dart
105 lines
4.2 KiB
Dart
import 'package:flutter/widgets.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:patbond_flutter/analytics/session_tracker.dart';
|
||
|
||
void main() {
|
||
final uuidV7 = RegExp(
|
||
r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$',
|
||
);
|
||
|
||
group('SessionTracker', () {
|
||
test('冷启动生成 UUIDv7 格式的 sessionId', () {
|
||
final tracker = SessionTracker();
|
||
expect(tracker.sessionId, matches(uuidV7));
|
||
});
|
||
|
||
test('短后台(≤30 分钟)resume 后沿用原 sessionId', () {
|
||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||
final tracker = SessionTracker(now: () => clock);
|
||
final original = tracker.sessionId;
|
||
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||
clock = clock.add(const Duration(minutes: 30));
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
|
||
expect(tracker.sessionId, original);
|
||
});
|
||
|
||
test('长后台(>30 分钟)resume 后生成新 sessionId', () {
|
||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||
final tracker = SessionTracker(now: () => clock);
|
||
final original = tracker.sessionId;
|
||
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||
clock = clock.add(const Duration(minutes: 31));
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
|
||
expect(tracker.sessionId, isNot(original));
|
||
expect(tracker.sessionId, matches(uuidV7));
|
||
});
|
||
|
||
test('真实生命周期级联下退后台时刻不被 inactive 覆盖', () {
|
||
// 前台恢复时状态机级联 paused → hidden → inactive → resumed,
|
||
// 若 inactive 覆盖记时,31 分钟长后台会被误判为 0 分钟。
|
||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||
final tracker = SessionTracker(now: () => clock);
|
||
final original = tracker.sessionId;
|
||
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||
clock = clock.add(const Duration(minutes: 31));
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
|
||
expect(tracker.sessionId, isNot(original));
|
||
});
|
||
|
||
test('连续多次短后台幂等,仅超阈值才换新', () {
|
||
var clock = DateTime.utc(2026, 9, 7, 10);
|
||
final tracker = SessionTracker(now: () => clock);
|
||
final original = tracker.sessionId;
|
||
|
||
for (var i = 0; i < 3; i++) {
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||
clock = clock.add(const Duration(minutes: 10));
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
expect(tracker.sessionId, original);
|
||
}
|
||
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||
clock = clock.add(const Duration(minutes: 35));
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
expect(tracker.sessionId, isNot(original));
|
||
});
|
||
|
||
test('前后台回调成对触发一次,生命周期级联不重复', () {
|
||
var leaveCount = 0;
|
||
var enterCount = 0;
|
||
final tracker = SessionTracker(
|
||
onLeaveForeground: () => leaveCount++,
|
||
onEnterForeground: () => enterCount++,
|
||
);
|
||
|
||
// 退后台级联:inactive → hidden → paused 只回调 leave 一次。
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.paused);
|
||
expect(leaveCount, 1);
|
||
expect(enterCount, 0);
|
||
|
||
// 回前台级联:hidden → inactive → resumed 只回调 enter 一次。
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.hidden);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.inactive);
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
expect(leaveCount, 1);
|
||
expect(enterCount, 1);
|
||
|
||
// 已在前台重复 resumed(冷启动首个 resumed 同形)不触发 enter。
|
||
tracker.didChangeAppLifecycleState(AppLifecycleState.resumed);
|
||
expect(enterCount, 1);
|
||
});
|
||
});
|
||
}
|