import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:yx_tracking_flutter/src/config/analytics_config.dart'; import 'package:yx_tracking_flutter/src/config/config_manager.dart'; import 'package:yx_tracking_flutter/src/core/analytics_core.dart'; import 'package:yx_tracking_flutter/src/core/interceptors.dart'; import 'package:yx_tracking_flutter/src/core/scheduler.dart'; import 'package:yx_tracking_flutter/src/model/device_info.dart'; import 'package:yx_tracking_flutter/src/model/event.dart'; import 'package:yx_tracking_flutter/src/model/system_dim_info.dart'; import 'package:yx_tracking_flutter/src/network/api_client.dart'; import 'package:yx_tracking_flutter/src/storage/event_storage.dart'; class MemoryEventStorage implements EventStorage { int _nextId = 1; final Map _store = {}; final List insertedEvents = []; @override Future init() async {} @override Future insert(Event event) async { final id = _nextId++; insertedEvents.add(event); _store[id] = StoredEvent( id: id, event: event, retryCount: event.retryCount, createTime: event.createTime, ); return id; } @override Future> fetchBatch(int limit) async { final items = _store.values.toList() ..sort((a, b) => a.createTime.compareTo(b.createTime)); return items.take(limit).toList(growable: false); } @override Future> fetchRecent(int limit) async { final items = _store.values.toList() ..sort((a, b) => b.createTime.compareTo(a.createTime)); return items.take(limit).toList(growable: false); } @override Future deleteByIds(List ids) async { for (final id in ids) { _store.remove(id); } } @override Future count() async => _store.length; @override Future trimToMaxSize(int maxSize) async { if (maxSize <= 0 || _store.length <= maxSize) { return 0; } final items = _store.values.toList() ..sort((a, b) => a.createTime.compareTo(b.createTime)); final overflow = items.length - maxSize; for (var i = 0; i < overflow; i++) { _store.remove(items[i].id); } return overflow; } @override Future updateRetryCount(int id, int retryCount) async { final current = _store[id]; if (current == null) { return; } final updatedEvent = current.event.copyWith(retryCount: retryCount); _store[id] = current.copyWith( retryCount: retryCount, event: updatedEvent, ); } @override Future dispose() async {} StoredEvent? byId(int id) => _store[id]; StoredEvent? get singleOrNull { if (_store.length != 1) { return null; } return _store.values.first; } } class FakeApiClient extends ApiClient { FakeApiClient(super.config); ApiException? exceptionToThrow; final List> sentBatches = >[]; @override Future sendBatch(List events) async { sentBatches.add(events); final exception = exceptionToThrow; if (exception != null) { throw exception; } } } class TestConfigManager extends ConfigManager { SystemDimInfo? _currentConfig; TestConfigManager({ required super.config, SystemDimInfo? initialConfig, }) : _currentConfig = initialConfig, super(refreshInterval: Duration.zero); @override SystemDimInfo? get currentConfig => _currentConfig; void setCurrentConfig(SystemDimInfo? value) { _currentConfig = value; } @override Future init() async {} @override Future fetchAndCacheConfig({bool force = false}) async {} @override Future forceRefresh() async {} @override Future dispose() async {} } class NoopScheduler extends Scheduler { NoopScheduler({ required super.interval, required super.onTick, }); @override void start() {} @override void stop() {} } class RecordingInterceptor extends AnalyticsInterceptor { RecordingInterceptor({ this.onBefore, this.onAfter, }); final FutureOr Function(Event event)? onBefore; final FutureOr Function(Event event, SendResult result)? onAfter; int beforeCalls = 0; int afterCalls = 0; final List afterResults = []; @override FutureOr beforeSend(Event event) async { beforeCalls += 1; final handler = onBefore; if (handler == null) { return event; } return handler(event); } @override FutureOr afterSend(Event event, SendResult result) async { afterCalls += 1; afterResults.add(result); final handler = onAfter; if (handler == null) { return; } await handler(event, result); } } const DeviceInfo _testDeviceInfo = DeviceInfo( os: 'test-os', model: 'test-model', screenResolution: '100x200', ); AnalyticsConfig _testConfig({ bool enableDebug = true, int batchSize = 20, int maxRetryCount = 2, int maxCacheSize = 5000, }) { return AnalyticsConfig( systemCode: 'TEST_APP', endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', clientType: 3, enableDebug: enableDebug, batchSize: batchSize, flushInterval: 3600, maxRetryCount: maxRetryCount, maxCacheSize: maxCacheSize, enableMetrics: false, ); } SystemDimInfo _dimInfo({ List events = const [], List tags = const [], SdkStrategy? strategy, }) { return SystemDimInfo( systemInfo: const SystemInfo(raw: {}), eventDefinitions: events, tagDefinitions: tags, sdkStrategy: strategy, lastFetchedAt: DateTime.now(), ); } Future _drainMicrotasks() async { await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); } void main() { group('AnalyticsCore.flush', () { test('成功发送后删除事件', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final config = _testConfig(batchSize: 5); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_A'), ]), ); await core.track('EVENT_A'); await core.track('EVENT_A'); expect(await storage.count(), 2); await core.flush(force: true); expect(apiClient.sentBatches, isNotEmpty); expect(await storage.count(), 0); await core.dispose(); }); test('可重试失败会增加 retryCount 并保留事件', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final config = _testConfig(enableDebug: true, batchSize: 10, maxRetryCount: 3); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_RETRY'), ]), ); apiClient.exceptionToThrow = const ApiException( message: 'network', retryable: true, ); await core.track('EVENT_RETRY'); await core.flush(force: true); final stored = storage.singleOrNull; expect(stored, isNotNull); expect(stored!.retryCount, 1); apiClient.exceptionToThrow = null; await core.flush(force: true); expect(await storage.count(), 0); await core.dispose(); }); test('不可重试失败会直接删除事件', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final config = _testConfig(enableDebug: true, batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_DROP'), ]), ); apiClient.exceptionToThrow = const ApiException( message: 'bad request', retryable: false, statusCode: 400, ); await core.track('EVENT_DROP'); await core.flush(force: true); expect(await storage.count(), 0); await core.dispose(); }); }); group('AnalyticsCore.validation (release)', () { test('release 模式会写入校验标记', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; final config = _testConfig(enableDebug: false, batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo( events: const [ EventDefinition(eventCode: 'KNOWN_EVENT'), ], tags: const [ TagDefinition( tagName: 'requiredTag', tagType: 'string', isRequired: true, ), ], ), ); // 触发两个校验问题:未知事件 + 缺少必填 tag。 await core.track('UNKNOWN_EVENT'); await _drainMicrotasks(); final inserted = storage.insertedEvents.last; final tags = inserted.customTags ?? const {}; expect(tags['_sdk_invalid_event'], true); expect(tags['_sdk_missing_tags'], contains('requiredTag')); await core.dispose(); }); }); group('AnalyticsCore.fallback', () { test('无配置时仍可 track/flush(降级为 Phase 1 行为)', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(_testConfig(enableDebug: true, batchSize: 10)); await core.track('EVENT_NO_CONFIG'); await core.flush(force: true); expect(apiClient.sentBatches, isNotEmpty); await core.dispose(); }); }); group('AnalyticsCore.strategy', () { test('全局策略关闭时直接丢弃事件', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; final config = _testConfig(enableDebug: true, batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo( events: const [ EventDefinition(eventCode: 'EVENT_DISABLED'), ], strategy: const SdkStrategy( enabled: false, defaultSampleRate: 1.0, eventSettings: {}, ), ), ); await core.track('EVENT_DISABLED'); expect(await storage.count(), 0); await core.dispose(); }); test('事件级策略 disabled 时丢弃事件', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; final config = _testConfig(enableDebug: true, batchSize: 10); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo( events: const [ EventDefinition(eventCode: 'EVENT_OFF'), ], strategy: const SdkStrategy( enabled: true, defaultSampleRate: 1.0, eventSettings: { 'EVENT_OFF': EventStrategy(enabled: false, sampleRate: 0.0), }, ), ), ); await core.track('EVENT_OFF'); expect(await storage.count(), 0); await core.dispose(); }); test('采样策略根据随机数决定是否入库', () async { final strategy = const SdkStrategy( enabled: true, defaultSampleRate: 1.0, eventSettings: { 'EVENT_SAMPLE': EventStrategy(enabled: true, sampleRate: 0.5), }, ); final storageDropped = MemoryEventStorage(); late TestConfigManager configManagerDropped; final coreDropped = AnalyticsCore( storageFactory: () => storageDropped, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManagerDropped = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), randomDouble: () => 0.9, ); await coreDropped.init(_testConfig(enableDebug: true, batchSize: 10)); configManagerDropped.setCurrentConfig( _dimInfo( events: const [ EventDefinition(eventCode: 'EVENT_SAMPLE'), ], strategy: strategy, ), ); await coreDropped.track('EVENT_SAMPLE'); expect(await storageDropped.count(), 0); await coreDropped.dispose(); final storageKept = MemoryEventStorage(); late TestConfigManager configManagerKept; final coreKept = AnalyticsCore( storageFactory: () => storageKept, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManagerKept = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), randomDouble: () => 0.1, ); await coreKept.init(_testConfig(enableDebug: true, batchSize: 10)); configManagerKept.setCurrentConfig( _dimInfo( events: const [ EventDefinition(eventCode: 'EVENT_SAMPLE'), ], strategy: strategy, ), ); await coreKept.track('EVENT_SAMPLE'); expect(await storageKept.count(), 1); await coreKept.dispose(); }); }); group('AnalyticsCore.interceptors', () { test('拦截器可修改事件并收到 afterSend 回调', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final interceptor = RecordingInterceptor( onBefore: (event) { final tags = Map.from( event.customTags ?? const {}, ); tags['intercepted'] = true; return event.copyWith(customTags: tags); }, ); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(_testConfig(enableDebug: true, batchSize: 10)); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_INTERCEPT'), ]), ); core.addInterceptor(interceptor); await core.track('EVENT_INTERCEPT'); await core.flush(force: true); final sentEvent = apiClient.sentBatches.single.single; expect(sentEvent.customTags?['intercepted'], true); expect(interceptor.afterCalls, 1); expect(interceptor.afterResults.single.success, true); await core.dispose(); }); test('拦截器返回 null 会拦截并删除事件', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; final interceptor = RecordingInterceptor(onBefore: (_) => null); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(_testConfig(enableDebug: true, batchSize: 10)); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_DROP_BY_INTERCEPTOR'), ]), ); core.addInterceptor(interceptor); await core.track('EVENT_DROP_BY_INTERCEPTOR'); await core.flush(force: true); expect(await storage.count(), 0); await core.dispose(); }); test('拦截器异常不会影响后续拦截器与发送', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final throwing = RecordingInterceptor( onBefore: (_) => throw StateError('boom'), ); final tagging = RecordingInterceptor( onBefore: (event) { final tags = Map.from( event.customTags ?? const {}, ); tags['tagged'] = 'yes'; return event.copyWith(customTags: tags); }, ); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(_testConfig(enableDebug: true, batchSize: 10)); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_THROW'), ]), ); core.addInterceptor(throwing); core.addInterceptor(tagging); await core.track('EVENT_THROW'); await core.flush(force: true); final sentEvent = apiClient.sentBatches.single.single; expect(sentEvent.customTags?['tagged'], 'yes'); await core.dispose(); }); }); group('AnalyticsCore.metrics', () { test('reportMetricsNow 会生成指标事件并包含计数', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final config = AnalyticsConfig( systemCode: 'TEST_APP', endpointBaseUrl: 'https://example.com/api/ExternalEventlogs', clientType: 3, enableDebug: true, batchSize: 10, flushInterval: 3600, enableMetrics: true, metricsReportInterval: const Duration(days: 1), ); final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(config); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_METRIC'), ]), ); await core.track('EVENT_METRIC'); await core.track('EVENT_METRIC'); await core.flush(force: true); await core.reportMetricsNow(); final metricsSendEvents = storage.insertedEvents .where((e) => e.eventType == 'SDK_METRICS_SEND') .toList(growable: false); expect(metricsSendEvents, isNotEmpty); final metricsParams = metricsSendEvents.last.eventParams ?? const {}; expect(metricsParams['sentCount'], 2); expect(apiClient.sentBatches, isNotEmpty); await core.dispose(); }); }); group('MemoryEventStorage contract', () { Event buildEvent(String type, DateTime createTime) { final ts = createTime.millisecondsSinceEpoch; return Event( systemCode: 'TEST_APP', eventType: type, userInfo: null, clientType: 3, clientTimestamp: ts, timestamp: createTime.toUtc().toIso8601String(), deviceInfo: _testDeviceInfo, eventParams: null, customTags: null, createTime: createTime, ); } test('trimToMaxSize 会删除最旧事件并返回删除数', () async { final storage = MemoryEventStorage(); await storage.init(); final now = DateTime.now(); await storage.insert(buildEvent('E1', now.subtract(const Duration(seconds: 3)))); await storage.insert(buildEvent('E2', now.subtract(const Duration(seconds: 2)))); await storage.insert(buildEvent('E3', now.subtract(const Duration(seconds: 1)))); final trimmed = await storage.trimToMaxSize(2); expect(trimmed, 1); expect(await storage.count(), 2); expect(storage.byId(1), isNull); }); test('fetchRecent 按时间降序返回', () async { final storage = MemoryEventStorage(); await storage.init(); final now = DateTime.now(); await storage.insert(buildEvent('OLD', now.subtract(const Duration(seconds: 2)))); await storage.insert(buildEvent('MID', now.subtract(const Duration(seconds: 1)))); await storage.insert(buildEvent('NEW', now)); final recent = await storage.fetchRecent(2); expect(recent.map((e) => e.event.eventType).toList(), ['NEW', 'MID']); }); test('updateRetryCount 会同时更新 stored 与 event.retryCount', () async { final storage = MemoryEventStorage(); await storage.init(); final now = DateTime.now(); final id = await storage.insert(buildEvent('RETRY', now)); await storage.updateRetryCount(id, 2); final stored = storage.byId(id); expect(stored, isNotNull); expect(stored!.retryCount, 2); expect(stored.event.retryCount, 2); }); }); group('Integration & performance (mocked)', () { test('断网失败后恢复可补发', () async { final storage = MemoryEventStorage(); late FakeApiClient apiClient; late TestConfigManager configManager; final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => apiClient = FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init(_testConfig(enableDebug: false, batchSize: 10)); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_OFFLINE'), ]), ); apiClient.exceptionToThrow = const ApiException( message: 'offline', retryable: true, ); await core.track('EVENT_OFFLINE'); await core.flush(force: true); expect(await storage.count(), 1); apiClient.exceptionToThrow = null; await core.flush(force: true); expect(await storage.count(), 0); await core.dispose(); }); test('高频 track 1 万次不会崩溃且数据可入库', () async { final storage = MemoryEventStorage(); late TestConfigManager configManager; final core = AnalyticsCore( storageFactory: () => storage, apiClientFactory: (c) => FakeApiClient(c), configManagerFactory: (c) => configManager = TestConfigManager(config: c), deviceInfoCollector: () async => _testDeviceInfo, schedulerFactory: (interval, onTick) => NoopScheduler(interval: interval, onTick: onTick), ); await core.init( _testConfig( enableDebug: false, batchSize: 20000, maxCacheSize: 20000, ), ); configManager.setCurrentConfig( _dimInfo(events: const [ EventDefinition(eventCode: 'EVENT_STRESS'), ]), ); for (var i = 0; i < 10000; i++) { await core.track('EVENT_STRESS'); } expect(await storage.count(), 10000); await core.dispose(); }); }); }