33b993ca0c
CI / flutter-gates (push) Successful in 1m6s
- AnalyticsEventStore:shared_preferences 分段存储(每段 ≤20 条、 总上限 500 超限丢最旧整段)、冷启动恢复、损坏段/损坏索引容错、 droppedCount 丢弃诊断计数 - AnalyticsService 接入持久化队列:上传拿到终态(202/4xx)才删段 实现 at-least-once;冲刷按段拼批 ≤50 条循环上传(契约单批上限); 取批即封段,冲刷在途新事件写入新开放段不丢 - app.dart 冷启动 restore() 恢复离线积压并冲刷(13 号 §3.4 触发点) - 保留第一波语义:flushNow()、4xx 毒丸丢弃、满 20 条冲刷触发 - 新增 13 个单测(恢复/上限淘汰/损坏容错/202 清段/flushNow 协同/ 分批上传),全套 64 测试全绿 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
5.2 KiB
Dart
160 lines
5.2 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:patbond_flutter/analytics/analytics_event_store.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
Map<String, dynamic> event(int seq) => {
|
|
'eventId': 'id-$seq',
|
|
'eventName': 'auth_login_succeeded',
|
|
'seq': seq,
|
|
};
|
|
|
|
void main() {
|
|
group('AnalyticsEventStore', () {
|
|
test('事件持久化后新实例冷启动可恢复(顺序保持旧 → 新)', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
for (var i = 0; i < 45; i++) {
|
|
await store.add(event(i));
|
|
}
|
|
|
|
final reborn = AnalyticsEventStore();
|
|
await reborn.restore();
|
|
|
|
expect(reborn.length, 45);
|
|
expect(reborn.events.first['seq'], 0);
|
|
expect(reborn.events.last['seq'], 44);
|
|
// 45 条按容量 20 分段应为 3 段
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final index =
|
|
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!) as List;
|
|
expect(index.length, 3);
|
|
});
|
|
|
|
test('超过 500 条总上限时丢最旧整段', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
for (var i = 0; i < 520; i++) {
|
|
await store.add(event(i));
|
|
}
|
|
|
|
// 第 501 条触发淘汰最旧一段(20 条),此后回落到上限内
|
|
expect(store.length, 500);
|
|
expect(store.events.first['seq'], 20);
|
|
expect(store.events.last['seq'], 519);
|
|
expect(store.droppedCount, 20);
|
|
});
|
|
|
|
test('损坏段丢弃不崩溃,其余段正常恢复且索引被清理', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
AnalyticsEventStore.segIndexKey: jsonEncode(['bad', 'good']),
|
|
'${AnalyticsEventStore.segKeyPrefix}bad': '{not-valid-json[[',
|
|
'${AnalyticsEventStore.segKeyPrefix}good': jsonEncode([event(7)]),
|
|
});
|
|
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
|
|
expect(store.length, 1);
|
|
expect(store.events.single['seq'], 7);
|
|
expect(store.droppedCount, 1);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(prefs.getString('${AnalyticsEventStore.segKeyPrefix}bad'), isNull);
|
|
expect(jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!), [
|
|
'good',
|
|
]);
|
|
});
|
|
|
|
test('段索引本身损坏时清扫孤儿段、从空队列重建', () async {
|
|
SharedPreferences.setMockInitialValues({
|
|
AnalyticsEventStore.segIndexKey: 'garbage-not-json',
|
|
'${AnalyticsEventStore.segKeyPrefix}orphan': jsonEncode([event(1)]),
|
|
});
|
|
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
|
|
expect(store.length, 0);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(
|
|
prefs.getString('${AnalyticsEventStore.segKeyPrefix}orphan'),
|
|
isNull,
|
|
);
|
|
// 可继续正常写入
|
|
await store.add(event(2));
|
|
expect(store.length, 1);
|
|
});
|
|
|
|
test('takeBatch 封段:在途批次不受新事件影响,删段后新事件保留', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
for (var i = 0; i < 5; i++) {
|
|
await store.add(event(i));
|
|
}
|
|
|
|
final batch = store.takeBatch(50);
|
|
// 模拟上传在途期间进入的新事件
|
|
await store.add(event(99));
|
|
|
|
expect(batch.events.length, 5);
|
|
await store.removeSegments(batch.segmentIds);
|
|
expect(store.length, 1);
|
|
expect(store.events.single['seq'], 99);
|
|
});
|
|
|
|
test('takeBatch 按段拼批不超过单批上限', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
for (var i = 0; i < 60; i++) {
|
|
await store.add(event(i));
|
|
}
|
|
|
|
// 段容量 20、单批上限 50 → 每批最多 2 整段(40 条)
|
|
final first = store.takeBatch(50);
|
|
expect(first.events.length, 40);
|
|
await store.removeSegments(first.segmentIds);
|
|
final second = store.takeBatch(50);
|
|
expect(second.events.length, 20);
|
|
});
|
|
|
|
test('上传成功删段后 shared_preferences 无残留', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final store = AnalyticsEventStore();
|
|
await store.restore();
|
|
for (var i = 0; i < 20; i++) {
|
|
await store.add(event(i));
|
|
}
|
|
|
|
final batch = store.takeBatch(50);
|
|
await store.removeSegments(batch.segmentIds);
|
|
|
|
expect(store.length, 0);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(
|
|
prefs.getKeys().where(
|
|
(key) => key.startsWith(AnalyticsEventStore.segKeyPrefix),
|
|
),
|
|
isEmpty,
|
|
);
|
|
expect(
|
|
jsonDecode(prefs.getString(AnalyticsEventStore.segIndexKey)!),
|
|
isEmpty,
|
|
);
|
|
});
|
|
|
|
test('未调用 restore(持久化不可用)时降级为纯内存队列', () async {
|
|
final store = AnalyticsEventStore();
|
|
await store.add(event(1));
|
|
expect(store.length, 1);
|
|
final batch = store.takeBatch(50);
|
|
await store.removeSegments(batch.segmentIds);
|
|
expect(store.length, 0);
|
|
});
|
|
});
|
|
}
|