Initial commit: 91dj android client dev_1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user