857 lines
26 KiB
Dart
857 lines
26 KiB
Dart
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<int, StoredEvent> _store = <int, StoredEvent>{};
|
||
final List<Event> insertedEvents = <Event>[];
|
||
|
||
@override
|
||
Future<void> init() async {}
|
||
|
||
@override
|
||
Future<int> 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<List<StoredEvent>> 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<List<StoredEvent>> 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<void> deleteByIds(List<int> ids) async {
|
||
for (final id in ids) {
|
||
_store.remove(id);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Future<int> count() async => _store.length;
|
||
|
||
@override
|
||
Future<int> 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<void> 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<void> 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<List<Event>> sentBatches = <List<Event>>[];
|
||
|
||
@override
|
||
Future<void> sendBatch(List<Event> 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<void> init() async {}
|
||
|
||
@override
|
||
Future<void> fetchAndCacheConfig({bool force = false}) async {}
|
||
|
||
@override
|
||
Future<void> forceRefresh() async {}
|
||
|
||
@override
|
||
Future<void> 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<Event?> Function(Event event)? onBefore;
|
||
final FutureOr<void> Function(Event event, SendResult result)? onAfter;
|
||
|
||
int beforeCalls = 0;
|
||
int afterCalls = 0;
|
||
final List<SendResult> afterResults = <SendResult>[];
|
||
|
||
@override
|
||
FutureOr<Event?> beforeSend(Event event) async {
|
||
beforeCalls += 1;
|
||
final handler = onBefore;
|
||
if (handler == null) {
|
||
return event;
|
||
}
|
||
return handler(event);
|
||
}
|
||
|
||
@override
|
||
FutureOr<void> 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<EventDefinition> events = const <EventDefinition>[],
|
||
List<TagDefinition> tags = const <TagDefinition>[],
|
||
SdkStrategy? strategy,
|
||
}) {
|
||
return SystemDimInfo(
|
||
systemInfo: const SystemInfo(raw: <String, dynamic>{}),
|
||
eventDefinitions: events,
|
||
tagDefinitions: tags,
|
||
sdkStrategy: strategy,
|
||
lastFetchedAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
Future<void> _drainMicrotasks() async {
|
||
await Future<void>.delayed(Duration.zero);
|
||
await Future<void>.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>[
|
||
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>[
|
||
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>[
|
||
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>[
|
||
EventDefinition(eventCode: 'KNOWN_EVENT'),
|
||
],
|
||
tags: const <TagDefinition>[
|
||
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 <String, dynamic>{};
|
||
|
||
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>[
|
||
EventDefinition(eventCode: 'EVENT_DISABLED'),
|
||
],
|
||
strategy: const SdkStrategy(
|
||
enabled: false,
|
||
defaultSampleRate: 1.0,
|
||
eventSettings: <String, EventStrategy>{},
|
||
),
|
||
),
|
||
);
|
||
|
||
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>[
|
||
EventDefinition(eventCode: 'EVENT_OFF'),
|
||
],
|
||
strategy: const SdkStrategy(
|
||
enabled: true,
|
||
defaultSampleRate: 1.0,
|
||
eventSettings: <String, EventStrategy>{
|
||
'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: <String, EventStrategy>{
|
||
'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>[
|
||
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>[
|
||
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<String, dynamic>.from(
|
||
event.customTags ?? const <String, dynamic>{},
|
||
);
|
||
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>[
|
||
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>[
|
||
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<String, dynamic>.from(
|
||
event.customTags ?? const <String, dynamic>{},
|
||
);
|
||
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>[
|
||
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>[
|
||
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 <String, dynamic>{};
|
||
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(), <String>['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>[
|
||
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>[
|
||
EventDefinition(eventCode: 'EVENT_STRESS'),
|
||
]),
|
||
);
|
||
|
||
for (var i = 0; i < 10000; i++) {
|
||
await core.track('EVENT_STRESS');
|
||
}
|
||
|
||
expect(await storage.count(), 10000);
|
||
await core.dispose();
|
||
});
|
||
});
|
||
}
|