Initial commit: 91dj android client dev_1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-08 11:37:25 +07:00
co-authored by Claude Opus 5.5
commit 97dd6a739a
1262 changed files with 119813 additions and 0 deletions
@@ -0,0 +1,181 @@
import 'package:analytics_sdk/manager/ad_impression_manager.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:flutter_test/flutter_test.dart';
/// AdImpressionManager 去重逻辑回归测试
///
/// 覆盖 Bug 修复:
/// - _getUnreportedAdIds 异常时(返回 null)应降级为"继续上报(不去重)",
/// 而非被当作"全部已上报"而丢弃事件。
/// 修复点:analytics_sdk.dart track() 中的 null/empty 判断分离。
void main() {
const page = 'test_page';
setUp(() {
AdImpressionManager.instance.clear();
SessionManager.instance.reset();
});
tearDown(() {
AdImpressionManager.instance.clear();
SessionManager.instance.reset();
});
group('getUnreportedAdIds', () {
test('所有 adId 未上报时,返回完整列表', () {
final result =
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page);
expect(result, ['ad1', 'ad2', 'ad3']);
});
test('所有 adId 已上报时,返回空列表', () {
AdImpressionManager.instance.markAsReportedBatch('ad1,ad2,ad3', pageKey: page);
final result =
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page);
expect(result, isEmpty);
});
test('部分 adId 已上报时,只返回未上报的', () {
AdImpressionManager.instance.markAsReportedBatch('ad1,ad3', pageKey: page);
final result =
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page);
expect(result, ['ad2']);
});
test('空字符串 adId 返回空列表', () {
final result = AdImpressionManager.instance.getUnreportedAdIds('', pageKey: page);
expect(result, isEmpty);
});
test('含空白项的 adId 被 trim 后正确处理', () {
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page);
final result =
AdImpressionManager.instance.getUnreportedAdIds(' ad1 , ad2 ', pageKey: page);
expect(result, ['ad2']);
});
});
group('页面级去重:不同页面互不干扰', () {
test('同一 adId 在不同页面各自独立去重', () {
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: 'page_a');
// page_a 已上报
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_a'),
isEmpty,
);
// page_b 未上报
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_b'),
equals(['ad1']),
);
});
test('clearPage 只清除指定页面的去重记录', () {
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: 'page_a');
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: 'page_b');
AdImpressionManager.instance.clearPage('page_a');
// page_a 已清除,可重新上报
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_a'),
equals(['ad1']),
);
// page_b 不受影响,仍然已上报
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: 'page_b'),
isEmpty,
);
});
test('离开页面再返回后同一广告可重新上报', () {
// 第一次进入 list_page,上报广告
AdImpressionManager.instance.markAsReportedBatch('ad1,ad2,ad3', pageKey: 'list_page');
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: 'list_page'),
isEmpty,
reason: '同一次页面访问内不重复上报',
);
// 离开 list_page(模拟页面退出清除去重)
AdImpressionManager.instance.clearPage('list_page');
// 再次进入 list_page,广告可重新上报
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: 'list_page'),
equals(['ad1', 'ad2', 'ad3']),
reason: '页面退出后重新进入,广告可重新上报',
);
});
});
group('session 级去重:新 session 后同一 adId 可重新上报', () {
test('标记后 clear() 使 adId 重新出现在未上报列表', () {
AdImpressionManager.instance.markAsReportedBatch('adX', pageKey: page);
expect(AdImpressionManager.instance.getUnreportedAdIds('adX', pageKey: page), isEmpty);
// SessionManager._createNewSession() 内部调用 AdImpressionManager.instance.clear()
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
expect(
AdImpressionManager.instance.getUnreportedAdIds('adX', pageKey: page),
equals(['adX']),
reason: '新 session 后 adX 应视为未上报',
);
});
test('多 adId:新 session 后全部解除去重', () {
AdImpressionManager.instance.markAsReportedBatch('ad1,ad2,ad3', pageKey: page);
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page), isEmpty);
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2,ad3', pageKey: page),
equals(['ad1', 'ad2', 'ad3']),
);
});
test('新 session 后重新标记,同 session 内再次去重', () {
// Session 1:标记 ad1
SessionManager.instance.getSessionId();
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page);
// Session 2:ad1 解除去重,重新标记
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
expect(AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page), equals(['ad1']));
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page);
// Session 2 内 ad1 再次被去重
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page),
isEmpty,
reason: '同 session 2 内 ad1 已标记,不重复',
);
});
});
group('null 与 empty 语义区分(Bug 1 核心)', () {
/// 在 analytics_sdk.dart track() 里:
/// null = _getUnreportedAdIds 发生异常 → 降级:继续上报(不去重)
/// empty = 所有 adId 已上报 → 跳过整个事件
///
/// 修复前:null || empty 统一 return(事件丢失)
/// 修复后:只有 empty 才 return,null 降级继续上报
///
/// 此处通过 AdImpressionManager.getUnreportedAdIds 验证:
/// "全部已上报"场景返回 [](非 null),框架需区分处理。
test('全部已上报时 getUnreportedAdIds 返回 [] 而非 null', () {
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page);
final result = AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page);
// 必须是 empty list,不能是 null
// analytics_sdk.dart 只应在结果为 empty(非 null)时跳过事件
expect(result, isNotNull);
expect(result, isEmpty);
});
});
}
@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:analytics_sdk/analytics_sdk.dart';
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/utils/aes_gcm_util.dart';
import 'package:flutter_test/flutter_test.dart';
/// GCM 解密固定测试 + validateEncryptedConfig 覆盖
///
/// 目的:
/// 1. 保护 SdkConfig.decryptKey + AesGcmUtil pipeline 的完整性。
/// 2. 覆盖 validateEncryptedConfig() 的所有逻辑分支,
/// 确保上线前格式校验工具本身是可靠的。
void main() {
const key = SdkConfig.decryptKey;
group('AesGcmUtil GCM 加解密', () {
test('域名列表:加密后可正确解密还原', () {
final domains = ['https://api.example.com', 'https://api2.example.com'];
final ciphertext = AesGcmUtil.encrypt(key, domains);
final plaintext = AesGcmUtil.decryptResponseAuto(ciphertext);
final decoded = jsonDecode(plaintext);
expect(decoded, isA<List>());
expect(List<String>.from(decoded), equals(domains));
});
test('事件类型列表:加密后可正确解密还原', () {
final eventTypes = ['app_page_view', 'ad_impression', 'app_click'];
final ciphertext = AesGcmUtil.encrypt(key, eventTypes);
final plaintext = AesGcmUtil.decryptResponseAuto(ciphertext);
final decoded = jsonDecode(plaintext);
expect(decoded, isA<List>());
expect(List<String>.from(decoded), equals(eventTypes));
});
test('decryptResponseAuto 与 decryptToString 结果一致', () {
final data = ['https://api.example.com'];
final ciphertext = AesGcmUtil.encrypt(key, data);
final auto = AesGcmUtil.decryptResponseAuto(ciphertext);
final explicit = AesGcmUtil.decryptToString(key, ciphertext);
expect(auto, equals(explicit));
});
test('错误密文抛出异常而非静默失败', () {
expect(
() => AesGcmUtil.decryptResponseAuto('not-a-valid-ciphertext'),
throwsException,
);
});
});
group('AnalyticsSdk.validateEncryptedConfig()', () {
test('域名列表加密串 → success=true, preview=第一个域名', () {
final domains = ['https://api.example.com', 'https://api2.example.com'];
final encrypted = AesGcmUtil.encrypt(key, domains);
final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted);
expect(result['success'], isTrue);
expect(result['preview'], equals('https://api.example.com'));
expect(result['error'], isNull);
});
test('事件类型列表加密串 → success=true, preview=第一个事件类型', () {
final eventTypes = ['app_page_view', 'ad_impression'];
final encrypted = AesGcmUtil.encrypt(key, eventTypes);
final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted);
expect(result['success'], isTrue);
expect(result['preview'], equals('app_page_view'));
expect(result['error'], isNull);
});
test('enabled_event_types 格式 → success=true', () {
final payload = {'enabled_event_types': ['app_click', 'ad_impression']};
final encrypted = AesGcmUtil.encrypt(key, payload);
final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted);
expect(result['success'], isTrue);
expect(result['preview'], equals('app_click'));
});
test('解密成功但无法识别格式 → success=false, preview=前60字符', () {
// 加密一个 Map,其中没有已知 key
final unrecognized = {'unknown_key': 'some_value'};
final encrypted = AesGcmUtil.encrypt(key, unrecognized);
final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted);
expect(result['success'], isFalse);
expect(result['preview'], isNotNull);
expect((result['preview'] as String).length, lessThanOrEqualTo(60));
expect(result['error'], contains('0 个元素'));
});
test('无效密文 → success=false, error 包含"解密失败"', () {
final result = AnalyticsSdk.instance.validateEncryptedConfig('not-valid-ciphertext');
expect(result['success'], isFalse);
expect(result['error'], contains('解密失败'));
expect(result['preview'], isNull);
});
test('空列表加密串 → success=false, error 包含"0 个元素"', () {
final encrypted = AesGcmUtil.encrypt(key, <String>[]);
final result = AnalyticsSdk.instance.validateEncryptedConfig(encrypted);
expect(result['success'], isFalse);
expect(result['error'], contains('0 个元素'));
});
});
}
@@ -0,0 +1,39 @@
import 'package:analytics_sdk/entity/analytics_utils.dart';
import 'package:analytics_sdk/utils/device_fingerprint_util.dart';
import 'package:flutter_test/flutter_test.dart';
/// fp_version 公共字段测试
///
/// 确保 getDeviceCommonFields() 和 generateCommonFields() 均包含
/// fp_version 字段,且值与 DeviceFingerprintUtil.kVersion 一致。
void main() {
group('AnalyticsUtils fp_version', () {
setUp(() {
AnalyticsUtils.configure(
appId: 'test_app',
channel: 'test',
deviceFingerprint: 'fp_abc',
fingerprintVersion: DeviceFingerprintUtil.kVersion,
);
});
test('getDeviceCommonFields() 包含 fp_version 字段', () {
final fields = AnalyticsUtils.getDeviceCommonFields();
expect(fields.containsKey('fp_version'), isTrue);
expect(fields['fp_version'], equals(DeviceFingerprintUtil.kVersion));
});
test('generateCommonFields() 包含 fp_version 字段', () {
final fields = AnalyticsUtils.generateCommonFields('test_event');
expect(fields.containsKey('fp_version'), isTrue);
expect(fields['fp_version'], equals(DeviceFingerprintUtil.kVersion));
});
test('fp_version 默认值为 kVersion', () {
// 不传 fingerprintVersion,依然应有默认值
AnalyticsUtils.configure(appId: 'test2');
final fields = AnalyticsUtils.getDeviceCommonFields();
expect(fields['fp_version'], isNotEmpty);
});
});
}
@@ -0,0 +1,39 @@
import 'package:analytics_sdk/entity/analytics_utils.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('AnalyticsUtils.configure field semantics', () {
setUp(() {
AnalyticsUtils.configure(
appId: 'app1',
channel: 'ch1',
uid: 'user1',
device: 'Android',
deviceId: 'dev1',
);
});
test('configure with null does NOT clear existing value', () {
AnalyticsUtils.configure(uid: null);
expect(AnalyticsUtils.uid, 'user1');
});
test('configure with empty string DOES clear existing value', () {
AnalyticsUtils.configure(uid: '');
expect(AnalyticsUtils.uid, '');
});
test('setUid clears uid to empty string', () {
AnalyticsUtils.setUid('');
expect(AnalyticsUtils.uid, '');
});
test('reset clears all fields to null', () {
AnalyticsUtils.reset();
expect(AnalyticsUtils.appId, isNull);
expect(AnalyticsUtils.uid, isNull);
expect(AnalyticsUtils.channel, isNull);
expect(AnalyticsUtils.deviceId, isNull);
});
});
}
@@ -0,0 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
}
@@ -0,0 +1 @@
void main() {}
@@ -0,0 +1,94 @@
import 'dart:convert';
import 'dart:io';
import 'package:analytics_sdk/config/sdk_config.dart';
import 'package:analytics_sdk/manager/domain_manager.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// DomainManager 磁盘缓存测试
///
/// 使用 MethodChannel mock 将 path_provider 重定向到系统临时目录,
/// 测试缓存读写逻辑而无需真实设备文件系统。
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
const channel = MethodChannel('plugins.flutter.io/path_provider');
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('dm_cache_test_');
// 将 path_provider 所有路径请求重定向到临时目录
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (MethodCall call) async {
return tempDir.path;
});
// 重置单例缓存路径(确保每个 test 都从全新状态开始)
DomainManager.instance.reset();
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
if (tempDir.existsSync()) {
await tempDir.delete(recursive: true);
}
});
group('DomainManager 磁盘缓存', () {
test('缓存文件不存在时 loadCachedDomains() 返回空列表', () async {
final result = await DomainManager.instance.loadCachedDomains();
expect(result, isEmpty);
});
test('写入缓存后 loadCachedDomains() 返回正确域名列表', () async {
final domains = ['https://api.example.com', 'https://api2.example.com'];
// 直接写入缓存文件模拟之前 initWithDomains 写入的结果
final cacheFile =
File('${tempDir.path}/${SdkConfig.domainCacheFileName}');
await cacheFile.writeAsString(jsonEncode(domains));
final result = await DomainManager.instance.loadCachedDomains();
expect(result, equals(domains));
});
test('缓存文件 JSON 格式损坏时 loadCachedDomains() 返回空列表(不抛异常)', () async {
final cacheFile =
File('${tempDir.path}/${SdkConfig.domainCacheFileName}');
await cacheFile.writeAsString('not valid json {{{');
expect(
() async => await DomainManager.instance.loadCachedDomains(),
returnsNormally,
);
final result = await DomainManager.instance.loadCachedDomains();
expect(result, isEmpty);
});
test('空字符串或空格不被解析为有效域名', () async {
final cacheFile =
File('${tempDir.path}/${SdkConfig.domainCacheFileName}');
await cacheFile.writeAsString(jsonEncode([' ', '', 'https://ok.com']));
final result = await DomainManager.instance.loadCachedDomains();
expect(result, equals(['https://ok.com']));
});
});
group('DomainManager.reset()', () {
test('reset() 后 fastestDomain 和 reportDomains 均为空', () async {
// 先通过缓存文件让 loadCachedDomains 有数据
final cacheFile =
File('${tempDir.path}/${SdkConfig.domainCacheFileName}');
await cacheFile.writeAsString(jsonEncode(['https://api.example.com']));
// reset() 只清内存状态,不删文件
DomainManager.instance.reset();
expect(DomainManager.instance.fastestDomain, isNull);
expect(DomainManager.instance.reportDomains, isEmpty);
// 缓存文件应仍然存在
expect(cacheFile.existsSync(), isTrue);
});
});
}
@@ -0,0 +1,128 @@
import 'dart:io';
import 'package:analytics_sdk/manager/event_processor.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
const channel = MethodChannel('plugins.flutter.io/path_provider');
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('ep_logic_test_');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationDocumentsDirectory') {
return tempDir.path;
}
return null;
});
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
await tempDir.delete(recursive: true);
});
group('uploadBatch concurrent guard', () {
test('resetState clears _isUploading so next uploadBatch can proceed', () async {
final processor = EventProcessor();
await processor.initPersistence();
processor.enqueueEvent({
'event': 'test_event',
'event_id': 'eid1',
'app_id': 'app1',
'sid': 'sid1',
'client_ts': 1711929600,
'device_id': 'dev1',
});
expect(processor.queueLength, 1);
processor.resetState();
await processor.uploadBatch();
expect(processor.queueLength, 1);
});
});
group('queue removal correctness', () {
test('enqueueEvent during upload does not corrupt removal', () {
final processor = EventProcessor();
for (int i = 1; i <= 3; i++) {
processor.enqueueEvent({
'event': 'evt',
'event_id': 'eid_$i',
'app_id': 'app1',
'sid': 'sid1',
'client_ts': 1711929600,
'device_id': 'dev1',
});
}
expect(processor.queueLength, 3);
processor.enqueueEvent({
'event': 'evt',
'event_id': 'eid_4',
'app_id': 'app1',
'sid': 'sid1',
'client_ts': 1711929600,
'device_id': 'dev1',
});
expect(processor.queueLength, 4);
});
});
group('dispose cache buffer flush', () {
test('disposeAsync writes buffered events to cache file', () async {
final processor = EventProcessor();
await processor.initPersistence();
// Enqueue 3 events (below _cacheBufferThreshold of 10, so they stay in buffer)
for (int i = 1; i <= 3; i++) {
processor.enqueueEvent({
'event': 'evt',
'event_id': 'buf_$i',
'app_id': 'app1',
'sid': 'sid1',
'client_ts': 1711929600,
'device_id': 'dev1',
});
}
// Call disposeAsync — should flush buffer to disk
await processor.disposeAsync();
// Wait for any microtasks to complete
await Future.delayed(Duration(milliseconds: 100));
// Verify cache file has the 3 events
final cacheFile = File('${tempDir.path}/data_plus_events_cache.jsonl');
expect(await cacheFile.exists(), isTrue, reason: 'cache file should exist after dispose');
final lines = (await cacheFile.readAsLines())
.where((l) => l.trim().isNotEmpty)
.toList();
expect(lines.length, 3);
});
});
group('SessionManager re-init', () {
test('reset + initialize creates a new session ID', () {
SessionManager.instance.reset();
SessionManager.instance.initialize();
final firstSid = SessionManager.instance.currentSessionId;
expect(firstSid, isNotNull);
SessionManager.instance.reset();
SessionManager.instance.initialize();
final secondSid = SessionManager.instance.currentSessionId;
expect(secondSid, isNotNull);
expect(secondSid, isNot(equals(firstSid)));
});
});
}
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'dart:io';
import 'package:analytics_sdk/manager/event_processor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// EventProcessor tombstone 缓存测试
///
/// 验证:
/// 1. 上报成功后写 tombstone,不重写主文件
/// 2. loadCachedEvents 用 tombstone 过滤已上报事件
/// 3. tombstone 达到阈值时触发压缩
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
const channel = MethodChannel('plugins.flutter.io/path_provider');
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('ep_tombstone_test_');
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationDocumentsDirectory') {
return tempDir.path;
}
return null;
});
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
await tempDir.delete(recursive: true);
});
/// 写几条事件到主缓存文件
Future<void> writeCacheLines(Directory dir, List<Map<String, dynamic>> events) async {
final file = File('${dir.path}/data_plus_events_cache.jsonl');
final lines = events.map(jsonEncode).join('\n');
await file.writeAsString('$lines\n');
}
/// 读 tombstone 文件内容
Future<Set<String>> readTombstone(Directory dir) async {
final file = File('${dir.path}/data_plus_events_tombstone.txt');
if (!await file.exists()) return {};
final content = await file.readAsString();
return content.split('\n').where((l) => l.isNotEmpty).toSet();
}
test('_removeEventsFromCache 追加写 tombstone,不重写主文件', () async {
final processor = EventProcessor();
await processor.initPersistence();
final events = [
{'event_id': 'id_1', 'event': 'page_view'},
{'event_id': 'id_2', 'event': 'click'},
{'event_id': 'id_3', 'event': 'purchase'},
];
await writeCacheLines(tempDir, events);
final cacheFile = File('${tempDir.path}/data_plus_events_cache.jsonl');
final sizeBefore = await cacheFile.length();
// 上报成功,移除前两条
await processor.removeEventsFromCacheForTest(events.sublist(0, 2));
// 主文件大小不变(没有重写)
expect(await cacheFile.length(), equals(sizeBefore));
// tombstone 包含两个 ID
final ids = await readTombstone(tempDir);
expect(ids, containsAll(['id_1', 'id_2']));
expect(ids, isNot(contains('id_3')));
});
test('loadCachedEvents 用 tombstone 过滤,只恢复未上报的事件', () async {
final processor = EventProcessor();
await processor.initPersistence();
final events = [
{'event_id': 'id_1', 'event': 'page_view'},
{'event_id': 'id_2', 'event': 'click'},
{'event_id': 'id_3', 'event': 'purchase'},
];
await writeCacheLines(tempDir, events);
// 预先写 tombstone,标记 id_1 已上报
final tombstone = File('${tempDir.path}/data_plus_events_tombstone.txt');
await tombstone.writeAsString('id_1\n');
await processor.loadCachedEvents();
// 只有 id_2 和 id_3 进入队列
expect(processor.queueLength, equals(2));
// 两个文件都被删除
expect(await File('${tempDir.path}/data_plus_events_cache.jsonl').exists(), isFalse);
expect(await tombstone.exists(), isFalse);
});
test('tombstone 为空时 loadCachedEvents 恢复全部事件', () async {
final processor = EventProcessor();
await processor.initPersistence();
final events = [
{'event_id': 'id_1', 'event': 'page_view'},
{'event_id': 'id_2', 'event': 'click'},
];
await writeCacheLines(tempDir, events);
await processor.loadCachedEvents();
expect(processor.queueLength, equals(2));
});
}
@@ -0,0 +1,142 @@
import 'package:analytics_sdk/entity/analytics_utils.dart';
import 'package:analytics_sdk/utils/event_validator.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// EventValidator 回归测试
///
/// 覆盖三个历史 bug 修复路径:
/// 1. device 传入空字符串时,事件不应被丢弃
/// 2. 逗号分隔字段清洗后为空时,字段应被移除而非保留空字符串
/// 3. generateEventId() fallback 值符合 ^[A-Za-z0-9]{1,32}$ 正则
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// 为 SessionManager(内部依赖 path_provider)提供 mock
const channel = MethodChannel('plugins.flutter.io/path_provider');
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'getApplicationDocumentsDirectory') {
return '/tmp/test_validator';
}
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
// 构造一个基础合法事件,仅覆盖要测试的字段
Map<String, dynamic> baseEvent({Map<String, dynamic>? overrides}) {
final base = <String, dynamic>{
'event': 'test_event',
'event_id': 'abc123',
'app_id': 'test_app',
'sid': 'sess_001',
'client_ts': 1704067200,
'device_id': 'device_001',
'payload': <String, dynamic>{},
};
if (overrides != null) base.addAll(overrides);
return base;
}
group('EventValidator 回归:device 空字符串', () {
test('device 为空字符串时,事件不被丢弃(应正常返回)', () {
final event = baseEvent(overrides: {'device': ''});
final result = EventValidator.validate(event);
// 空字符串 device 应视为未提供,事件正常通过
expect(result, isNotNull, reason: 'device 空字符串不应导致事件被丢弃');
});
test('device 为合法值 android 时,自动纠正大小写', () {
final event = baseEvent(overrides: {'device': 'android'});
final result = EventValidator.validate(event);
expect(result, isNotNull);
expect(result!['device'], equals('Android'));
});
test('device 为无法识别的值时,事件应被丢弃', () {
final event = baseEvent(overrides: {'device': 'Windows'});
final result = EventValidator.validate(event);
expect(result, isNull, reason: '无法识别的 device 应丢弃事件');
});
});
group('EventValidator 回归:逗号分隔字段清洗后为空', () {
test('ad_id 为纯空格逗号时,清洗后为空,字段应被移除', () {
final event = baseEvent(overrides: {
'payload': {'ad_id': ' , , '},
});
final result = EventValidator.validate(event);
expect(result, isNotNull);
expect(
(result!['payload'] as Map<String, dynamic>).containsKey('ad_id'),
isFalse,
reason: '清洗后为空的逗号分隔字段应被移除,而非保留空字符串',
);
});
test('ad_id 有合法值时,正常保留并清洗', () {
final event = baseEvent(overrides: {
'payload': {'ad_id': 'ad_001 , , ad_002'},
});
final result = EventValidator.validate(event);
expect(result, isNotNull);
expect(
(result!['payload'] as Map<String, dynamic>)['ad_id'],
equals('ad_001,ad_002'),
);
});
test('novel_tag_key 清洗后为空时,字段被移除', () {
final event = baseEvent(overrides: {
'payload': {'novel_tag_key': ' '},
});
final result = EventValidator.validate(event);
expect(result, isNotNull);
expect(
(result!['payload'] as Map<String, dynamic>).containsKey('novel_tag_key'),
isFalse,
);
});
});
group('generateEventId() 回归:fallback 值格式合规', () {
final idPattern = RegExp(r'^[A-Za-z0-9]{1,32}$');
test('正常路径生成的 event_id 符合 ^[A-Za-z0-9]{1,32}\$', () {
final id = AnalyticsUtils.generateEventId(['event', 'app', 'uid', 'ts']);
expect(idPattern.hasMatch(id), isTrue,
reason: 'MD5 生成的 event_id 应符合正则: $id');
});
test('空列表生成的 event_id 符合正则', () {
final id = AnalyticsUtils.generateEventId([]);
expect(idPattern.hasMatch(id), isTrue,
reason: '空列表 fallback event_id 应符合正则: $id');
});
test('生成的 event_id 长度不超过 32', () {
final id = AnalyticsUtils.generateEventId(['a', 'b', 'c']);
expect(id.length, lessThanOrEqualTo(32));
});
test('fallback h\${abs} 格式:字母数字开头,无连字符或下划线', () {
// 验证 h 前缀 + 纯数字的形式符合正则
final hashFallback = 'h${('abc').hashCode.abs()}';
expect(idPattern.hasMatch(hashFallback), isTrue,
reason: 'h\${hashCode.abs()} 应符合正则: $hashFallback');
});
test('fallback t\${milliseconds} 格式符合正则', () {
final tsFallback = 't${DateTime.now().millisecondsSinceEpoch}';
// 13 位时间戳 + 't' 前缀 = 14 字符,≤ 32
expect(idPattern.hasMatch(tsFallback), isTrue,
reason: 't\${ms} 应符合正则: $tsFallback');
});
});
}
@@ -0,0 +1,59 @@
import 'package:analytics_sdk/utils/event_validator.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(() {
SessionManager.instance.reset();
SessionManager.instance.initialize();
});
tearDown(() {
SessionManager.instance.reset();
});
group('EventValidator type coercion', () {
Map<String, dynamic> makeEvent({dynamic uid, dynamic channel}) {
return {
'event': 'test_event',
'event_id': 'abc123',
'app_id': 'app1',
'sid': SessionManager.instance.getSessionId(),
'client_ts': 1711929600,
'device_id': 'dev1',
if (uid != null) 'uid': uid,
if (channel != null) 'channel': channel,
};
}
test('int uid is coerced to string', () {
final result = EventValidator.validate(makeEvent(uid: 12345));
expect(result, isNotNull);
expect(result!['uid'], '12345');
});
test('double uid is coerced to string', () {
final result = EventValidator.validate(makeEvent(uid: 123.0));
expect(result, isNotNull);
expect(result!['uid'], '123.0');
});
test('int channel is coerced to string', () {
final result = EventValidator.validate(makeEvent(channel: 100));
expect(result, isNotNull);
expect(result!['channel'], '100');
});
test('string uid is preserved', () {
final result = EventValidator.validate(makeEvent(uid: 'user_abc'));
expect(result, isNotNull);
expect(result!['uid'], 'user_abc');
});
test('bool uid is coerced to string', () {
final result = EventValidator.validate(makeEvent(uid: true));
expect(result, isNotNull);
expect(result!['uid'], 'true');
});
});
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:analytics_sdk/analytics_sdk.dart';
import 'package:analytics_sdk/enum/user_type_enum.dart';
import 'package:analytics_sdk/manager/user_manager.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
setUp(() {
UserManager.instance.updateUserType(UserTypeEnum.normal.label);
});
group('logoutUser', () {
test('logoutUser resets uid and userType to normal', () {
AnalyticsSdk.setUserIdAndType(
userId: 'user123',
userTypeEnum: UserTypeEnum.vip,
);
expect(UserManager.instance.userType, UserTypeEnum.vip.label);
AnalyticsSdk.logoutUser();
expect(UserManager.instance.userType, UserTypeEnum.normal.label);
});
test('logoutUser is idempotent — calling twice has same result', () {
AnalyticsSdk.setUserIdAndType(
userId: 'user123',
userTypeEnum: UserTypeEnum.vip,
);
AnalyticsSdk.logoutUser();
AnalyticsSdk.logoutUser();
expect(UserManager.instance.userType, UserTypeEnum.normal.label);
});
});
}
@@ -0,0 +1,68 @@
import 'package:analytics_sdk/manager/page_name_manager.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PageNameMapper.normalizeKey', () {
test('普通字符串不变', () {
expect(PageNameMapper.normalizeKey('home'), 'home');
});
test('去掉单个前导斜杠', () {
expect(PageNameMapper.normalizeKey('/home'), 'home');
});
test('去掉前导斜杠后剩余部分保留', () {
expect(PageNameMapper.normalizeKey('/video_detail'), 'video_detail');
});
test('纯 / 保留原值(避免 pageKey 变空字符串)', () {
expect(PageNameMapper.normalizeKey('/'), '/');
});
test('空字符串不变', () {
expect(PageNameMapper.normalizeKey(''), '');
});
test('双斜杠开头只去掉第一个', () {
expect(PageNameMapper.normalizeKey('//home'), '/home');
});
test('斜杠在中间不受影响', () {
expect(PageNameMapper.normalizeKey('a/b'), 'a/b');
});
test('斜杠在结尾不受影响', () {
expect(PageNameMapper.normalizeKey('home/'), 'home/');
});
});
group('PageNameMapper.getPageName', () {
setUp(() {
PageNameMapper.clearCustom();
});
tearDown(() {
PageNameMapper.clearCustom();
});
test('无映射时返回归一化后的 key', () {
expect(PageNameMapper.getPageName('/home'), 'home');
});
test('有映射时返回映射名称(带斜杠注册)', () {
PageNameMapper.addMapping('/home', '首页');
expect(PageNameMapper.getPageName('/home'), '首页');
expect(PageNameMapper.getPageName('home'), '首页');
});
test('有映射时返回映射名称(不带斜杠注册)', () {
PageNameMapper.addMapping('home', '首页');
expect(PageNameMapper.getPageName('/home'), '首页');
expect(PageNameMapper.getPageName('home'), '首页');
});
test('纯 / 时 getPageName 返回 /', () {
expect(PageNameMapper.getPageName('/'), '/');
});
});
}
@@ -0,0 +1,189 @@
import 'package:analytics_sdk/manager/ad_impression_manager.dart';
import 'package:analytics_sdk/manager/session_manager.dart';
import 'package:flutter_test/flutter_test.dart';
/// SessionManager 回归测试
///
/// 覆盖 Bug 修复:
/// - _shouldCreateNewSession 是查询方法,不应在非超时分支清空 _backgroundTime。
/// 修复前:getSessionId() 在后台期间被调用时(如推送通知触发 track()),
/// 非超时分支的 `_backgroundTime = null` 会提前清空后台时间,
/// 导致后续 onAppForeground() 无法判断后台时长,旧会话被错误保留。
/// 修复点:session_manager.dart _shouldCreateNewSession 中只在超时分支清空。
void main() {
const page = 'test_page';
setUp(() {
SessionManager.instance.reset();
AdImpressionManager.instance.clear();
});
tearDown(() {
SessionManager.instance.reset();
AdImpressionManager.instance.clear();
});
group('_shouldCreateNewSession 不应提前清空 _backgroundTime(Bug 2 核心)', () {
test('onAppBackground 后 backgroundTime 非 null', () {
SessionManager.instance.getSessionId(); // 确保已初始化
SessionManager.instance.onAppBackground();
expect(
SessionManager.instance.backgroundTimeForTest,
isNotNull,
reason: 'onAppBackground() 应设置 _backgroundTime',
);
});
test('后台期间调用 getSessionId() 不应清空 backgroundTime', () {
SessionManager.instance.getSessionId(); // 初始化会话
SessionManager.instance.onAppBackground();
// 模拟后台期间有推送通知触发了 track() → getSessionId()
// 后台时间极短(< 30 min),不应触发新会话,但也不应清空 _backgroundTime
SessionManager.instance.getSessionId();
expect(
SessionManager.instance.backgroundTimeForTest,
isNotNull,
reason: '后台期间调用 getSessionId() 不应清空 _backgroundTime,'
'否则后续 onAppForeground() 无法正确判断后台时长',
);
});
test('onAppForeground 后 backgroundTime 被正确清空', () {
SessionManager.instance.getSessionId();
SessionManager.instance.onAppBackground();
SessionManager.instance.onAppForeground();
expect(
SessionManager.instance.backgroundTimeForTest,
isNull,
reason: 'onAppForeground() 执行完毕后应清空 _backgroundTime',
);
});
test('未调用 onAppBackground 时,getSessionId 不影响 backgroundTime(始终为 null)', () {
SessionManager.instance.getSessionId();
expect(SessionManager.instance.backgroundTimeForTest, isNull);
SessionManager.instance.getSessionId();
expect(SessionManager.instance.backgroundTimeForTest, isNull);
});
});
group('新 session 时清空广告去重集(session 级去重)', () {
test('新 session 创建后 AdImpressionManager 被清空', () {
SessionManager.instance.getSessionId();
AdImpressionManager.instance.markAsReportedBatch('ad1,ad2', pageKey: page);
// 确认已标记
expect(AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2', pageKey: page), isEmpty);
// 重置触发新 session
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1,ad2', pageKey: page),
equals(['ad1', 'ad2']),
reason: '新 session 后同一 adId 应视为未上报',
);
});
test('同一 session 内标记后不再返回未上报', () {
SessionManager.instance.getSessionId();
AdImpressionManager.instance.markAsReportedBatch('ad1', pageKey: page);
// 没有新 session,ad1 应仍被去重
expect(
AdImpressionManager.instance.getUnreportedAdIds('ad1', pageKey: page),
isEmpty,
reason: '同 session 内 ad1 不应重复上报',
);
});
test('连续建立多个 session,每次都清空去重集', () {
const ids = 'ad1,ad2,ad3';
// Session 1
SessionManager.instance.getSessionId();
AdImpressionManager.instance.markAsReportedBatch(ids, pageKey: page);
expect(AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page), isEmpty);
// Session 2
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
expect(
AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page),
equals(['ad1', 'ad2', 'ad3']),
reason: 'Session 2 开始时去重集应已清空',
);
// Session 2 内再次标记
AdImpressionManager.instance.markAsReportedBatch(ids, pageKey: page);
expect(AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page), isEmpty);
// Session 3
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
expect(
AdImpressionManager.instance.getUnreportedAdIds(ids, pageKey: page),
equals(['ad1', 'ad2', 'ad3']),
reason: 'Session 3 开始时去重集应再次清空',
);
});
test('端到端:上报 → 同 session 去重 → 新 session → 再次可报', () {
const adId = 'banner_001';
// Step 1: 首次出现 → 未上报
SessionManager.instance.getSessionId();
expect(AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page), [adId]);
AdImpressionManager.instance.markAsReportedBatch(adId, pageKey: page);
// Step 2: 同 session 内再次出现 → 去重
expect(
AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page),
isEmpty,
reason: '同 session 内不重复上报',
);
// Step 3: 新 session
SessionManager.instance.reset();
SessionManager.instance.getSessionId();
// Step 4: 新 session 内首次出现 → 可上报
expect(
AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page),
equals([adId]),
reason: '新 session 内首次出现,应可上报',
);
// Step 5: 新 session 内标记后 → 再次去重
AdImpressionManager.instance.markAsReportedBatch(adId, pageKey: page);
expect(
AdImpressionManager.instance.getUnreportedAdIds(adId, pageKey: page),
isEmpty,
reason: '新 session 内已标记,不重复上报',
);
});
});
group('会话基础行为', () {
test('未初始化时 getSessionId 返回非空 ID', () {
final id = SessionManager.instance.getSessionId();
expect(id, isNotEmpty);
});
test('连续调用 getSessionId 返回相同 ID', () {
final id1 = SessionManager.instance.getSessionId();
final id2 = SessionManager.instance.getSessionId();
expect(id1, equals(id2));
});
test('reset 后 getSessionId 创建新 ID', () {
final id1 = SessionManager.instance.getSessionId();
SessionManager.instance.reset();
final id2 = SessionManager.instance.getSessionId();
expect(id1, isNot(equals(id2)));
});
});
}