- 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:
@@ -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 第三次失败 → 退避 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -137,5 +137,72 @@ void main() {
|
||||
expect(batchSizes, [40, 20]);
|
||||
expect(service.pendingEvents, isEmpty);
|
||||
});
|
||||
|
||||
test('429 不按毒丸丢弃:保段待退避重试(限流分支随后端落地)', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final (server, batchSizes) = await startServer(429);
|
||||
addTearDown(() => server.close(force: true));
|
||||
final store = AnalyticsEventStore();
|
||||
await store.restore();
|
||||
final service = buildService('http://127.0.0.1:${server.port}', store);
|
||||
|
||||
for (var i = 0; i < 20; i++) {
|
||||
await service.trackEvent('auth_login_succeeded', {'attemptSeq': i});
|
||||
}
|
||||
|
||||
expect(batchSizes, [20]);
|
||||
expect(service.pendingEvents.length, 20);
|
||||
expect(store.droppedCount, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('AnalyticsService anonymousId 持久化', () {
|
||||
test('首次 restore 落盘生成的 anonymousId', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final service = buildService('http://unused', AnalyticsEventStore());
|
||||
|
||||
await service.restore();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(
|
||||
prefs.getString(AnalyticsService.anonymousIdKey),
|
||||
service.anonymousId,
|
||||
);
|
||||
});
|
||||
|
||||
test('冷启动新实例沿用持久化 anonymousId,事件跨启动可归并', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final serviceA = buildService('http://unused', AnalyticsEventStore());
|
||||
await serviceA.restore();
|
||||
final firstLaunchId = serviceA.anonymousId;
|
||||
|
||||
// 模拟冷启动:新实例构造时是新的随机 v4,restore 后采用存储值。
|
||||
final serviceB = buildService('http://unused', AnalyticsEventStore());
|
||||
expect(serviceB.anonymousId, isNot(firstLaunchId));
|
||||
await serviceB.restore();
|
||||
|
||||
expect(serviceB.anonymousId, firstLaunchId);
|
||||
await serviceB.trackEvent('auth_login_succeeded');
|
||||
expect(serviceB.pendingEvents.single['anonymousId'], firstLaunchId);
|
||||
});
|
||||
|
||||
test('构造注入 anonymousId 的测试通道不被持久化覆盖', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
AnalyticsService.anonymousIdKey: 'anon-stored',
|
||||
});
|
||||
final service = AnalyticsService(
|
||||
apiBaseUrl: 'http://unused',
|
||||
getAccessToken: null,
|
||||
getSessionId: () => 'session-x',
|
||||
anonymousId: 'anon-injected',
|
||||
store: AnalyticsEventStore(),
|
||||
);
|
||||
|
||||
await service.restore();
|
||||
|
||||
expect(service.anonymousId, 'anon-injected');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getString(AnalyticsService.anonymousIdKey), 'anon-stored');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,5 +122,18 @@ void main() {
|
||||
// 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。
|
||||
expect(service.pendingEvents.length, 20);
|
||||
});
|
||||
|
||||
test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async {
|
||||
// 本文件从不 setMockInitialValues:SharedPreferences 走真实
|
||||
// 平台通道并抛异常,restore 须吞掉并保留构造时的临时 id。
|
||||
final service = buildService();
|
||||
final ephemeralId = service.anonymousId;
|
||||
|
||||
await service.restore();
|
||||
await service.trackEvent('auth_login_succeeded');
|
||||
|
||||
expect(service.anonymousId, ephemeralId);
|
||||
expect(service.pendingEvents.single['anonymousId'], ephemeralId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,5 +73,32 @@ void main() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user