mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-10 04:05:10 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
355
test/providers/app_provider_channel_info_test.dart
Normal file
355
test/providers/app_provider_channel_info_test.dart
Normal file
@@ -0,0 +1,355 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/models/device_info.dart';
|
||||
import 'package:meshcore_sar_app/providers/app_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
|
||||
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
||||
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
Uint8List _ownPublicKey() =>
|
||||
Uint8List.fromList([1, 2, 3, 4, 5, 6, ...List<int>.filled(26, 0)]);
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final locationTrackingService = LocationTrackingService();
|
||||
locationTrackingService.fastLocationUpdatesEnabled = false;
|
||||
locationTrackingService.fastLocationChannelIdx = null;
|
||||
locationTrackingService.isTracking = false;
|
||||
});
|
||||
|
||||
group('AppProvider channel info handling', () {
|
||||
test('treats zeroed unnamed non-public channel as deleted', () {
|
||||
expect(AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), isTrue);
|
||||
});
|
||||
|
||||
test('keeps unnamed non-public channel when secret is configured', () {
|
||||
final secret = Uint8List.fromList([1, ...List<int>.filled(15, 0)]);
|
||||
|
||||
expect(AppProvider.isDeletedChannelInfo(2, '', secret), isFalse);
|
||||
expect(AppProvider.channelContactName(2, ''), 'Channel 2');
|
||||
});
|
||||
});
|
||||
|
||||
group('AppProvider self replay handling', () {
|
||||
test('prefers self name over meshcore device name', () {
|
||||
expect(
|
||||
AppProvider.preferredSelfDisplayName(
|
||||
deviceName: 'MeshCore-dz0ny (SI)',
|
||||
selfName: 'dz0ny (SI)',
|
||||
),
|
||||
'dz0ny (SI)',
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores direct self replay without hops', () {
|
||||
final message = Message(
|
||||
id: 'dm-self',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'hello',
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(
|
||||
AppProvider.shouldIgnoreSelfReplay(
|
||||
message: message,
|
||||
ownPublicKey: _ownPublicKey(),
|
||||
ownName: 'dz0ny (SI)',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores room self replay without hops', () {
|
||||
final message = Message(
|
||||
id: 'room-self',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||
recipientPublicKey: Uint8List.fromList([9, 9, 9, 9, 9, 9]),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'hello room',
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(
|
||||
AppProvider.shouldIgnoreSelfReplay(
|
||||
message: message,
|
||||
ownPublicKey: _ownPublicKey(),
|
||||
ownName: 'dz0ny (SI)',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps direct self replay when it traversed hops', () {
|
||||
final message = Message(
|
||||
id: 'dm-self-routed',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'hello',
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(
|
||||
AppProvider.shouldIgnoreSelfReplay(
|
||||
message: message,
|
||||
ownPublicKey: _ownPublicKey(),
|
||||
ownName: 'dz0ny (SI)',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores channel self replay by self name without hops', () {
|
||||
final message = Message(
|
||||
id: 'channel-self',
|
||||
messageType: MessageType.channel,
|
||||
senderName: 'dz0ny (SI)',
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'hello public',
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(
|
||||
AppProvider.shouldIgnoreSelfReplay(
|
||||
message: message,
|
||||
ownPublicKey: _ownPublicKey(),
|
||||
ownName: 'dz0ny (SI)',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps channel self replay when it traversed hops', () {
|
||||
final message = Message(
|
||||
id: 'channel-self-routed',
|
||||
messageType: MessageType.channel,
|
||||
senderName: 'dz0ny (SI)',
|
||||
channelIdx: 0,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'hello public',
|
||||
receivedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(
|
||||
AppProvider.shouldIgnoreSelfReplay(
|
||||
message: message,
|
||||
ownPublicKey: _ownPublicKey(),
|
||||
ownName: 'dz0ny (SI)',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AppProvider channel location sharing state', () {
|
||||
test('clears app fallback sharing state when device disconnects', () async {
|
||||
final harness = _AppProviderHarness(
|
||||
connectionProvider: _FakeConnectionProvider(isConnected: true),
|
||||
);
|
||||
final locationTrackingService = harness.appProvider.locationTrackingService;
|
||||
|
||||
await locationTrackingService.updateFastLocationChannelIdx(3);
|
||||
await locationTrackingService.setFastLocationUpdatesEnabled(true);
|
||||
|
||||
harness.connectionProvider.setConnected(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(locationTrackingService.fastLocationUpdatesEnabled, isFalse);
|
||||
expect(locationTrackingService.fastLocationChannelIdx, isNull);
|
||||
expect(harness.appProvider.channelLocationSharingModeForChannel(3), isNull);
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
test('preserves hardware sharing state when device disconnects', () async {
|
||||
final harness = _AppProviderHarness(
|
||||
connectionProvider: _FakeConnectionProvider(
|
||||
isConnected: true,
|
||||
customVars: const {'gps': '1', 'fast_gps_channel': '7'},
|
||||
),
|
||||
);
|
||||
|
||||
await harness.appProvider.refreshChannelLocationSharingState();
|
||||
|
||||
expect(
|
||||
harness.appProvider.channelLocationSharingModeForChannel(7),
|
||||
ChannelLocationSharingMode.hardware,
|
||||
);
|
||||
|
||||
harness.connectionProvider.setConnected(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
harness.appProvider.channelLocationSharingModeForChannel(7),
|
||||
ChannelLocationSharingMode.hardware,
|
||||
);
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
|
||||
test('clears app fallback sharing state when device connects', () async {
|
||||
final harness = _AppProviderHarness(
|
||||
connectionProvider: _FakeConnectionProvider(isConnected: false),
|
||||
);
|
||||
final locationTrackingService = harness.appProvider.locationTrackingService;
|
||||
|
||||
await locationTrackingService.updateFastLocationChannelIdx(5);
|
||||
await locationTrackingService.setFastLocationUpdatesEnabled(true);
|
||||
|
||||
harness.connectionProvider.setConnected(true);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(locationTrackingService.fastLocationUpdatesEnabled, isFalse);
|
||||
expect(locationTrackingService.fastLocationChannelIdx, isNull);
|
||||
expect(harness.appProvider.channelLocationSharingModeForChannel(5), isNull);
|
||||
|
||||
await harness.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _AppProviderHarness {
|
||||
final _FakeConnectionProvider connectionProvider;
|
||||
final ContactsProvider contactsProvider;
|
||||
final MessagesProvider messagesProvider;
|
||||
final DrawingProvider drawingProvider;
|
||||
final ChannelsProvider channelsProvider;
|
||||
final VoiceProvider voiceProvider;
|
||||
final ip.ImageProvider imageProvider;
|
||||
late final AppProvider appProvider;
|
||||
|
||||
_AppProviderHarness({required this.connectionProvider})
|
||||
: contactsProvider = ContactsProvider(),
|
||||
messagesProvider = MessagesProvider(),
|
||||
drawingProvider = DrawingProvider(),
|
||||
channelsProvider = ChannelsProvider(),
|
||||
voiceProvider = VoiceProvider(
|
||||
codec: VoiceCodecService(),
|
||||
player: _FakeVoicePlayerService(),
|
||||
),
|
||||
imageProvider = ip.ImageProvider() {
|
||||
appProvider = AppProvider(
|
||||
connectionProvider: connectionProvider,
|
||||
contactsProvider: contactsProvider,
|
||||
messagesProvider: messagesProvider,
|
||||
drawingProvider: drawingProvider,
|
||||
channelsProvider: channelsProvider,
|
||||
voiceProvider: voiceProvider,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
if (appProvider.trafficStatsReportingService.isInitialized) {
|
||||
break;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
appProvider.dispose();
|
||||
voiceProvider.dispose();
|
||||
imageProvider.dispose();
|
||||
drawingProvider.dispose();
|
||||
messagesProvider.dispose();
|
||||
contactsProvider.dispose();
|
||||
connectionProvider.dispose();
|
||||
channelsProvider.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeConnectionProvider extends ConnectionProvider {
|
||||
DeviceInfo _deviceInfo;
|
||||
|
||||
_FakeConnectionProvider({
|
||||
required bool isConnected,
|
||||
Map<String, String>? customVars,
|
||||
}) : _deviceInfo = DeviceInfo(
|
||||
connectionState: isConnected
|
||||
? ConnectionState.connected
|
||||
: ConnectionState.disconnected,
|
||||
),
|
||||
_customVars = customVars ?? <String, String>{};
|
||||
|
||||
final Map<String, String> _customVars;
|
||||
|
||||
@override
|
||||
DeviceInfo get deviceInfo => _deviceInfo;
|
||||
|
||||
void setConnected(bool isConnected) {
|
||||
_deviceInfo = _deviceInfo.copyWith(
|
||||
connectionState: isConnected
|
||||
? ConnectionState.connected
|
||||
: ConnectionState.disconnected,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getCustomVars() async =>
|
||||
Map<String, String>.from(_customVars);
|
||||
}
|
||||
|
||||
class _FakeVoicePlayerService implements VoicePlayerService {
|
||||
final StreamController<void> _events = StreamController<void>.broadcast();
|
||||
bool _isPlaying = false;
|
||||
|
||||
@override
|
||||
bool get isPlaying => _isPlaying;
|
||||
|
||||
@override
|
||||
Duration get position => Duration.zero;
|
||||
|
||||
@override
|
||||
Duration get duration => Duration.zero;
|
||||
|
||||
@override
|
||||
Stream<void> get events => _events.stream;
|
||||
|
||||
@override
|
||||
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||
_isPlaying = true;
|
||||
_events.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_isPlaying = false;
|
||||
_events.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_events.close();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
60
test/providers/channels_provider_test.dart
Normal file
60
test/providers/channels_provider_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/channel.dart';
|
||||
import 'package:meshcore_sar_app/providers/channels_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('ChannelsProvider device sync preparation', () {
|
||||
test('clears runtime channel state before sync', () {
|
||||
final provider = ChannelsProvider();
|
||||
|
||||
provider.addOrUpdateChannel(
|
||||
index: 2,
|
||||
name: 'Ops',
|
||||
secret: Uint8List.fromList(List<int>.filled(16, 7)),
|
||||
);
|
||||
provider.selectChannel(2);
|
||||
|
||||
provider.prepareForDeviceSync();
|
||||
|
||||
expect(provider.channels, isEmpty);
|
||||
expect(provider.selectedChannelIndex, 0);
|
||||
expect(provider.selectedChannel, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChannelsProvider mesh hash lookup', () {
|
||||
test('resolves a unique channel display name by hash byte', () {
|
||||
final provider = ChannelsProvider();
|
||||
final channel = Channel.create(index: 3, name: '#ops');
|
||||
|
||||
provider.addOrUpdateChannelObject(channel);
|
||||
|
||||
expect(
|
||||
provider.getUniqueChannelDisplayNameByHashByte(channel.hashByte),
|
||||
'#ops',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not resolve ambiguous hash matches', () {
|
||||
final provider = ChannelsProvider();
|
||||
final secret = Uint8List.fromList(List<int>.filled(16, 7));
|
||||
final firstChannel = Channel.create(
|
||||
index: 1,
|
||||
name: 'Ops 1',
|
||||
explicitSecret: secret,
|
||||
);
|
||||
|
||||
provider.addOrUpdateChannelObject(firstChannel);
|
||||
provider.addOrUpdateChannelObject(
|
||||
Channel.create(index: 2, name: 'Ops 2', explicitSecret: secret),
|
||||
);
|
||||
|
||||
expect(
|
||||
provider.getUniqueChannelDisplayNameByHashByte(firstChannel.hashByte),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
86
test/providers/connection_provider_channel_slot_test.dart
Normal file
86
test/providers/connection_provider_channel_slot_test.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||
|
||||
void main() {
|
||||
group('ConnectionProvider channel slot occupancy', () {
|
||||
test('treats non-zero secret as configured', () {
|
||||
expect(
|
||||
ConnectionProvider.channelHasConfiguredSecret(Uint8List(16)),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
ConnectionProvider.channelHasConfiguredSecret(
|
||||
Uint8List.fromList([1, ...List<int>.filled(15, 0)]),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('treats duplicate hashtag channels as conflicts', () {
|
||||
expect(
|
||||
ConnectionProvider.isDuplicateChannelName(
|
||||
requestedName: '#sar',
|
||||
existingName: '#sar',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('allows private channels with the same name to coexist', () {
|
||||
expect(
|
||||
ConnectionProvider.isDuplicateChannelName(
|
||||
requestedName: 'Ops',
|
||||
existingName: 'Ops',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not treat arbitrary empty channel info as deletion', () {
|
||||
final provider = ConnectionProvider();
|
||||
expect(
|
||||
provider.shouldTreatChannelInfoAsDeleted(3, '', Uint8List(16)),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('finds first missing custom channel slot', () {
|
||||
expect(
|
||||
ConnectionProvider.firstAvailableChannelSlot(
|
||||
occupiedIndices: {1, 2, 4},
|
||||
maxChannels: 6,
|
||||
),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when all custom channel slots are occupied', () {
|
||||
expect(
|
||||
ConnectionProvider.firstAvailableChannelSlot(
|
||||
occupiedIndices: {1, 2, 3, 4},
|
||||
maxChannels: 5,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('private channels with same name do not block slot selection', () {
|
||||
expect(
|
||||
ConnectionProvider.isDuplicateChannelName(
|
||||
requestedName: 'RPT_admins',
|
||||
existingName: 'RPT_admins',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
ConnectionProvider.firstAvailableChannelSlot(
|
||||
occupiedIndices: {1, 2, 3},
|
||||
maxChannels: 6,
|
||||
),
|
||||
4,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
69
test/providers/contacts_provider_profile_scope_test.dart
Normal file
69
test/providers/contacts_provider_profile_scope_test.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/contact_storage_service.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Contact createContact({required Uint8List key, required String name}) {
|
||||
return Contact(
|
||||
publicKey: key,
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: (46.0569 * 1e6).round(),
|
||||
advLon: (14.5058 * 1e6).round(),
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'default',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'initializeEarly respects the active profile storage namespace',
|
||||
() async {
|
||||
final storage = ContactStorageService();
|
||||
final defaultContact = createContact(
|
||||
key: Uint8List.fromList(List<int>.filled(32, 1)),
|
||||
name: 'Default Contact',
|
||||
);
|
||||
final alphaContact = createContact(
|
||||
key: Uint8List.fromList(List<int>.filled(32, 2)),
|
||||
name: 'Alpha Contact',
|
||||
);
|
||||
|
||||
await storage.saveContacts([defaultContact]);
|
||||
await storage.saveContacts([alphaContact], namespace: 'alpha');
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
|
||||
final provider = ContactsProvider();
|
||||
await provider.initializeEarly();
|
||||
|
||||
final names = provider.contacts
|
||||
.where((contact) => !contact.isChannel)
|
||||
.map((contact) => contact.advName)
|
||||
.toList();
|
||||
|
||||
expect(names, <String>['Alpha Contact']);
|
||||
expect(provider.storageNamespace, 'alpha');
|
||||
},
|
||||
);
|
||||
}
|
||||
1092
test/providers/contacts_provider_test.dart
Normal file
1092
test/providers/contacts_provider_test.dart
Normal file
File diff suppressed because it is too large
Load Diff
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('FragmentAckWaitRegistry', () {
|
||||
test('completes multiple waiters registered for the same key', () async {
|
||||
final registry = FragmentAckWaitRegistry();
|
||||
|
||||
final first = registry.waitFor(
|
||||
'voice:1',
|
||||
timeout: const Duration(milliseconds: 200),
|
||||
);
|
||||
final second = registry.waitFor(
|
||||
'voice:1',
|
||||
timeout: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
expect(registry.complete('voice:1'), equals(2));
|
||||
expect(await first, isTrue);
|
||||
expect(await second, isTrue);
|
||||
});
|
||||
|
||||
test('times out and cleans up a waiter when no ack arrives', () async {
|
||||
final registry = FragmentAckWaitRegistry();
|
||||
|
||||
final completed = await registry.waitFor(
|
||||
'voice:2',
|
||||
timeout: const Duration(milliseconds: 20),
|
||||
);
|
||||
|
||||
expect(completed, isFalse);
|
||||
expect(registry.complete('voice:2'), equals(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('MessageDeliveryTracker', () {
|
||||
test('matches pending direct messages by contact', () {
|
||||
final tracker = MessageDeliveryTracker();
|
||||
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
|
||||
|
||||
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||
tracker.trackPendingDirectMessage('bob-1', bob);
|
||||
tracker.trackPendingDirectMessage('alice-2', alice);
|
||||
|
||||
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
|
||||
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
|
||||
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
|
||||
});
|
||||
|
||||
test('removeByMessageId clears pending queue state', () {
|
||||
final tracker = MessageDeliveryTracker();
|
||||
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||
|
||||
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||
tracker.mapAckTagToMessageId(42, 'alice-1');
|
||||
|
||||
tracker.removeByMessageId('alice-1');
|
||||
|
||||
expect(tracker.getMessageIdForAck(42), isNull);
|
||||
expect(tracker.popPendingDirectMessageId(alice), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
27
test/providers/helpers/ping_tracker_test.dart
Normal file
27
test/providers/helpers/ping_tracker_test.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/ping_tracker.dart';
|
||||
|
||||
void main() {
|
||||
Uint8List createPublicKey() => Uint8List.fromList(
|
||||
List<int>.generate(32, (index) => index + 1),
|
||||
);
|
||||
|
||||
group('PingTracker', () {
|
||||
test('completes pending ping when response uses public key prefix', () async {
|
||||
final tracker = PingTracker();
|
||||
final publicKey = createPublicKey();
|
||||
|
||||
final pingFuture = tracker.trackPing(
|
||||
publicKey: publicKey,
|
||||
wasDirectAttempt: true,
|
||||
);
|
||||
|
||||
tracker.markPingSuccessful(publicKey.sublist(0, 6));
|
||||
|
||||
await expectLater(pingFuture, completion(isTrue));
|
||||
expect(tracker.hasPendingPing(publicKey), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
161
test/providers/helpers/raw_session_retransmit_test.dart
Normal file
161
test/providers/helpers/raw_session_retransmit_test.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/raw_session_retransmit.dart';
|
||||
|
||||
class _Fragment {
|
||||
final int index;
|
||||
final Uint8List payload;
|
||||
|
||||
_Fragment(this.index, this.payload);
|
||||
}
|
||||
|
||||
Contact _buildContact({required int outPathLen}) {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: outPathLen,
|
||||
outPath: Uint8List.fromList(List<int>.generate(8, (i) => i + 1)),
|
||||
advName: 'Requester',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('serveCachedSessionFragments', () {
|
||||
test('returns false when sender callback is missing', () async {
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: _buildContact(outPathLen: 1),
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([1])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket: null,
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('returns false when requester has no learned path', () async {
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: _buildContact(outPathLen: -1),
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([1])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket:
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {},
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('returns false when requester path payload is empty', () async {
|
||||
final requester = Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List(0),
|
||||
advName: 'Requester',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: requester,
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([1])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket:
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {},
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
|
||||
test('sends only requested indices', () async {
|
||||
final sent = <Uint8List>[];
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: _buildContact(outPathLen: 1),
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([10])),
|
||||
_Fragment(1, Uint8List.fromList([20])),
|
||||
_Fragment(2, Uint8List.fromList([30])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket:
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {
|
||||
sent.add(payload);
|
||||
},
|
||||
requestedIndices: {1, 2},
|
||||
);
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(sent.length, equals(2));
|
||||
expect(sent[0], equals(Uint8List.fromList([20])));
|
||||
expect(sent[1], equals(Uint8List.fromList([30])));
|
||||
});
|
||||
|
||||
test('fails when no requested index matches cached fragments', () async {
|
||||
final ok = await serveCachedSessionFragments<_Fragment>(
|
||||
providerLabel: 'TestProvider',
|
||||
sessionId: 'deadbeef',
|
||||
requester: _buildContact(outPathLen: 1),
|
||||
fragments: [
|
||||
_Fragment(0, Uint8List.fromList([1])),
|
||||
],
|
||||
maxDirectPayloadHops: 3,
|
||||
indexOf: (f) => f.index,
|
||||
encodeBinary: (f) => f.payload,
|
||||
sendRawPacket:
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {},
|
||||
requestedIndices: {99},
|
||||
);
|
||||
|
||||
expect(ok, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
145
test/providers/helpers/session_metadata_restore_test.dart
Normal file
145
test/providers/helpers/session_metadata_restore_test.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('restoreSessionMetadataFromMessages', () {
|
||||
test(
|
||||
'restores voice and image session senders from persisted envelopes',
|
||||
() {
|
||||
final voiceEnvelope = VoiceEnvelope(
|
||||
sessionId: '00112233',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
total: 4,
|
||||
durationMs: 4000,
|
||||
);
|
||||
final imageEnvelope = ImageEnvelope(
|
||||
sessionId: '195cb2fb',
|
||||
format: ImageFormat.avif,
|
||||
total: 7,
|
||||
width: 118,
|
||||
height: 256,
|
||||
sizeBytes: 1069,
|
||||
);
|
||||
|
||||
final restored = restoreSessionMetadataFromMessages([
|
||||
Message(
|
||||
id: 'plain',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1,
|
||||
text: 'plain text',
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
),
|
||||
Message(
|
||||
id: 'voice',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 2,
|
||||
text: voiceEnvelope.encodeText(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList(
|
||||
[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff],
|
||||
),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
),
|
||||
Message(
|
||||
id: 'image',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 3,
|
||||
text: imageEnvelope.encode(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList(
|
||||
[0xfe, 0x8b, 0x30, 0xee, 0x05, 0xfc],
|
||||
),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(
|
||||
restored.voiceSenderKeyBySession,
|
||||
equals({'00112233': 'aabbccddeeff'}),
|
||||
);
|
||||
expect(
|
||||
restored.imageSenderKeyBySession,
|
||||
equals({'195cb2fb': 'fe8b30ee05fc'}),
|
||||
);
|
||||
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
|
||||
expect(
|
||||
restored.imageEnvelopeBySession['195cb2fb']?.sessionId,
|
||||
equals('195cb2fb'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps latest envelope when a session appears multiple times', () {
|
||||
final first = ImageEnvelope(
|
||||
sessionId: '195cb2fb',
|
||||
format: ImageFormat.avif,
|
||||
total: 7,
|
||||
width: 100,
|
||||
height: 100,
|
||||
sizeBytes: 900,
|
||||
);
|
||||
final second = ImageEnvelope(
|
||||
sessionId: '195cb2fb',
|
||||
format: ImageFormat.jpeg,
|
||||
total: 8,
|
||||
width: 118,
|
||||
height: 256,
|
||||
sizeBytes: 1069,
|
||||
);
|
||||
|
||||
final restored = restoreSessionMetadataFromMessages([
|
||||
Message(
|
||||
id: 'first',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1,
|
||||
text: first.encode(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
),
|
||||
Message(
|
||||
id: 'second',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 2,
|
||||
text: second.encode(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList(
|
||||
[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff],
|
||||
),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(restored.imageEnvelopeBySession.length, equals(1));
|
||||
expect(
|
||||
restored.imageSenderKeyBySession['195cb2fb'],
|
||||
equals('aabbccddeeff'),
|
||||
);
|
||||
expect(
|
||||
restored.imageEnvelopeBySession['195cb2fb']?.format,
|
||||
equals(ImageFormat.jpeg),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
50
test/providers/image_provider_cancel_test.dart
Normal file
50
test/providers/image_provider_cancel_test.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/image_provider.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
group('ImageProvider cancel receive', () {
|
||||
test('ignores incoming fragments after cancel until resumed', () {
|
||||
final provider = ImageProvider();
|
||||
const sessionId = '01020304';
|
||||
const envelope = ImageEnvelope(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
total: 2,
|
||||
width: 32,
|
||||
height: 32,
|
||||
sizeBytes: 4,
|
||||
);
|
||||
final fragment = ImagePacket(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
index: 0,
|
||||
total: 2,
|
||||
data: Uint8List.fromList([1, 2]),
|
||||
);
|
||||
|
||||
provider.registerEnvelope(envelope);
|
||||
provider.cancelIncomingSession(sessionId);
|
||||
|
||||
expect(provider.isReceiveCanceled(sessionId), isTrue);
|
||||
expect(provider.session(sessionId), isNull);
|
||||
|
||||
provider.addFragment(fragment, width: 32, height: 32);
|
||||
|
||||
expect(provider.session(sessionId), isNull);
|
||||
|
||||
provider.resumeIncomingSession(sessionId);
|
||||
provider.registerEnvelope(envelope);
|
||||
provider.addFragment(fragment, width: 32, height: 32);
|
||||
|
||||
expect(provider.isReceiveCanceled(sessionId), isFalse);
|
||||
expect(provider.session(sessionId)?.receivedCount, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
90
test/providers/image_provider_swarm_test.dart
Normal file
90
test/providers/image_provider_swarm_test.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/providers/image_provider.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
Contact _buildRequester() {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List.fromList([1, 2, 3, 4]),
|
||||
advName: 'Requester',
|
||||
lastAdvert: 1700000000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: 1700000000,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('ImageProvider swarm serving', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('serves requested fragments from received session cache', () async {
|
||||
final provider = ImageProvider();
|
||||
provider.registerEnvelope(
|
||||
const ImageEnvelope(
|
||||
sessionId: 'deadbeef',
|
||||
format: ImageFormat.avif,
|
||||
total: 3,
|
||||
width: 64,
|
||||
height: 64,
|
||||
sizeBytes: 300,
|
||||
),
|
||||
);
|
||||
|
||||
provider.addFragment(
|
||||
ImagePacket(
|
||||
sessionId: 'deadbeef',
|
||||
format: ImageFormat.avif,
|
||||
index: 0,
|
||||
total: 3,
|
||||
data: Uint8List.fromList([1, 2]),
|
||||
),
|
||||
width: 64,
|
||||
height: 64,
|
||||
);
|
||||
provider.addFragment(
|
||||
ImagePacket(
|
||||
sessionId: 'deadbeef',
|
||||
format: ImageFormat.avif,
|
||||
index: 2,
|
||||
total: 3,
|
||||
data: Uint8List.fromList([7, 8]),
|
||||
),
|
||||
width: 64,
|
||||
height: 64,
|
||||
);
|
||||
|
||||
final sent = <Uint8List>[];
|
||||
provider.sendRawPacketCallback =
|
||||
({
|
||||
required contactPath,
|
||||
required contactPathLen,
|
||||
required payload,
|
||||
}) async {
|
||||
sent.add(payload);
|
||||
};
|
||||
|
||||
final ok = await provider.serveSessionTo(
|
||||
sessionId: 'deadbeef',
|
||||
requester: _buildRequester(),
|
||||
requestedIndices: {2},
|
||||
);
|
||||
|
||||
expect(ok, isTrue);
|
||||
expect(provider.availableFragmentIndices('deadbeef'), [0, 2]);
|
||||
expect(sent, hasLength(1));
|
||||
expect(ImagePacket.tryParseBinary(sent.single)?.index, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
134
test/providers/media_provider_profile_scope_test.dart
Normal file
134
test/providers/media_provider_profile_scope_test.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/image_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/voice_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
|
||||
import 'package:meshcore_sar_app/services/voice_player_service.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
});
|
||||
|
||||
test('VoiceProvider reloads profile-scoped sessions', () async {
|
||||
final player = _FakeVoicePlayerService();
|
||||
final provider = VoiceProvider(codec: VoiceCodecService(), player: player);
|
||||
addTearDown(provider.dispose);
|
||||
|
||||
await provider.reloadProfileScopedState();
|
||||
provider.registerEnvelope(
|
||||
const VoiceEnvelope(
|
||||
sessionId: 'a1b2c3d4',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
total: 2,
|
||||
durationMs: 1600,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'beta',
|
||||
);
|
||||
await provider.reloadProfileScopedState();
|
||||
expect(provider.session('a1b2c3d4'), isNull);
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
await provider.reloadProfileScopedState();
|
||||
expect(provider.session('a1b2c3d4'), isNotNull);
|
||||
});
|
||||
|
||||
test('ImageProvider reloads profile-scoped sessions', () async {
|
||||
final provider = ImageProvider();
|
||||
|
||||
await provider.reloadProfileScopedState();
|
||||
provider.registerEnvelope(
|
||||
const ImageEnvelope(
|
||||
sessionId: 'a1b2c3d4',
|
||||
format: ImageFormat.avif,
|
||||
total: 2,
|
||||
width: 32,
|
||||
height: 32,
|
||||
sizeBytes: 8,
|
||||
),
|
||||
);
|
||||
provider.addFragment(
|
||||
ImagePacket(
|
||||
sessionId: 'a1b2c3d4',
|
||||
format: ImageFormat.avif,
|
||||
index: 0,
|
||||
total: 2,
|
||||
data: Uint8List.fromList([1, 2, 3, 4]),
|
||||
),
|
||||
width: 32,
|
||||
height: 32,
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'beta',
|
||||
);
|
||||
await provider.reloadProfileScopedState();
|
||||
expect(provider.session('a1b2c3d4'), isNull);
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
await provider.reloadProfileScopedState();
|
||||
expect(provider.session('a1b2c3d4'), isNotNull);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeVoicePlayerService implements VoicePlayerService {
|
||||
final StreamController<void> _events = StreamController<void>.broadcast();
|
||||
bool _isPlaying = false;
|
||||
|
||||
@override
|
||||
bool get isPlaying => _isPlaying;
|
||||
|
||||
@override
|
||||
Duration get position => Duration.zero;
|
||||
|
||||
@override
|
||||
Duration get duration => Duration.zero;
|
||||
|
||||
@override
|
||||
Stream<void> get events => _events.stream;
|
||||
|
||||
@override
|
||||
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||
_isPlaying = true;
|
||||
_events.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_isPlaying = false;
|
||||
_events.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_events.close();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
1096
test/providers/messages_provider_retransmission_test.dart
Normal file
1096
test/providers/messages_provider_retransmission_test.dart
Normal file
File diff suppressed because it is too large
Load Diff
423
test/providers/messages_provider_voice_test.dart
Normal file
423
test/providers/messages_provider_voice_test.dart
Normal file
@@ -0,0 +1,423 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Contact buildContact({
|
||||
required List<int> prefix,
|
||||
required ContactType type,
|
||||
String name = 'Test Contact',
|
||||
}) {
|
||||
final key = Uint8List.fromList([
|
||||
...prefix,
|
||||
...List<int>.generate(26, (index) => index),
|
||||
]);
|
||||
|
||||
return Contact(
|
||||
publicKey: key,
|
||||
type: type,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
group('MessagesProvider voice detection', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
LocationTrackingService().currentPosition = null;
|
||||
});
|
||||
|
||||
test('marks VE3 envelope messages as voice', () {
|
||||
final provider = MessagesProvider();
|
||||
final envelope = VoiceEnvelope(
|
||||
sessionId: 'deafbead',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
total: 3,
|
||||
durationMs: 2400,
|
||||
);
|
||||
|
||||
final message = Message(
|
||||
id: 'm1',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: envelope.encodeText(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
);
|
||||
|
||||
provider.addMessage(message);
|
||||
final stored = provider.messages.single;
|
||||
expect(stored.isVoice, isTrue);
|
||||
expect(stored.voiceId, equals('deafbead'));
|
||||
});
|
||||
|
||||
test('does not mark legacy V text packets as voice', () {
|
||||
final provider = MessagesProvider();
|
||||
final packet = VoicePacket(
|
||||
sessionId: '00112233',
|
||||
mode: VoicePacketMode.mode700c,
|
||||
index: 0,
|
||||
total: 1,
|
||||
codec2Data: Uint8List.fromList([1, 2, 3]),
|
||||
);
|
||||
final message = Message(
|
||||
id: 'm2',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000001,
|
||||
text: packet.encodeText(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
);
|
||||
|
||||
provider.addMessage(message);
|
||||
final stored = provider.messages.single;
|
||||
expect(stored.isVoice, isFalse);
|
||||
expect(stored.voiceId, isNull);
|
||||
});
|
||||
|
||||
test('persists received contact location snapshots', () async {
|
||||
final provider = MessagesProvider();
|
||||
final message = Message(
|
||||
id: 'm3',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000002,
|
||||
text: 'status update',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
);
|
||||
|
||||
provider.addMessage(
|
||||
message,
|
||||
contactLocationSnapshot: MessageContactLocation(
|
||||
location: const LatLng(46.0569, 14.5058),
|
||||
source: 'advert',
|
||||
capturedAt: DateTime.now(),
|
||||
sourceTimestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final restoredProvider = MessagesProvider();
|
||||
await restoredProvider.initialize();
|
||||
final snapshot = restoredProvider.getMessageContactLocation('m3');
|
||||
|
||||
expect(snapshot, isNotNull);
|
||||
expect(snapshot!.source, equals('advert'));
|
||||
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||
});
|
||||
|
||||
test('persists current device location for sent messages', () async {
|
||||
final provider = MessagesProvider();
|
||||
LocationTrackingService().currentPosition = Position(
|
||||
latitude: 46.0569,
|
||||
longitude: 14.5058,
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
|
||||
accuracy: 4.5,
|
||||
altitude: 310.0,
|
||||
altitudeAccuracy: 6.0,
|
||||
heading: 0.0,
|
||||
headingAccuracy: 0.0,
|
||||
speed: 0.0,
|
||||
speedAccuracy: 0.0,
|
||||
);
|
||||
|
||||
provider.addSentMessage(
|
||||
Message(
|
||||
id: 'sent-1',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000003,
|
||||
text: 'outgoing status',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final restoredProvider = MessagesProvider();
|
||||
await restoredProvider.initialize();
|
||||
final snapshot = restoredProvider.getMessageContactLocation('sent-1');
|
||||
|
||||
expect(snapshot, isNotNull);
|
||||
expect(snapshot!.source, equals('gps'));
|
||||
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||
expect(
|
||||
snapshot.sourceTimestamp,
|
||||
DateTime.fromMillisecondsSinceEpoch(1700000003000),
|
||||
);
|
||||
});
|
||||
|
||||
test('dedupes repeated incoming contact messages with same text', () {
|
||||
final provider = MessagesProvider();
|
||||
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'dup-1',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700001000,
|
||||
text: 'same payload',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: sender,
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'dup-2',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700001999,
|
||||
text: 'same payload',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: sender,
|
||||
),
|
||||
);
|
||||
|
||||
expect(provider.messages, hasLength(1));
|
||||
expect(provider.messages.single.id, equals('dup-1'));
|
||||
});
|
||||
|
||||
test('keeps separate incoming messages when text differs', () {
|
||||
final provider = MessagesProvider();
|
||||
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
|
||||
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'unique-1',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700002000,
|
||||
text: 'same payload',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: sender,
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'unique-2',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700002999,
|
||||
text: 'different payload',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: sender,
|
||||
),
|
||||
);
|
||||
|
||||
expect(provider.messages, hasLength(2));
|
||||
});
|
||||
|
||||
test('removed SAR markers stay hidden after provider restore', () async {
|
||||
final provider = MessagesProvider();
|
||||
final message = Message(
|
||||
id: 'sar-hidden',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000003,
|
||||
text: 'S:🧑:0:46.0569,14.5058:Hidden marker',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
);
|
||||
|
||||
provider.addMessage(message);
|
||||
expect(
|
||||
provider.sarMarkers.map((marker) => marker.id),
|
||||
contains(message.id),
|
||||
);
|
||||
|
||||
await provider.removeSarMarker(message.id);
|
||||
expect(provider.sarMarkers, isEmpty);
|
||||
|
||||
final restoredProvider = MessagesProvider();
|
||||
await restoredProvider.initialize();
|
||||
|
||||
expect(
|
||||
restoredProvider.messages.map((stored) => stored.id),
|
||||
contains(message.id),
|
||||
);
|
||||
expect(
|
||||
restoredProvider.sarMarkers.map((marker) => marker.id),
|
||||
isNot(contains(message.id)),
|
||||
);
|
||||
expect(restoredProvider.removedSarMarkerIds, contains(message.id));
|
||||
});
|
||||
|
||||
test('tracks and persists media transfer counts and downloaders', () async {
|
||||
final provider = MessagesProvider();
|
||||
final voiceEnvelope = VoiceEnvelope(
|
||||
sessionId: 'deafbead',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
total: 3,
|
||||
durationMs: 2400,
|
||||
);
|
||||
const imageEnvelope = ImageEnvelope(
|
||||
sessionId: '01020304',
|
||||
format: ImageFormat.avif,
|
||||
total: 2,
|
||||
width: 64,
|
||||
height: 64,
|
||||
sizeBytes: 2048,
|
||||
);
|
||||
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'voice1',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000010,
|
||||
text: voiceEnvelope.encodeText(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'image1',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000011,
|
||||
text: imageEnvelope.encode(),
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
),
|
||||
);
|
||||
|
||||
provider.recordMediaTransfer(
|
||||
sessionId: 'deafbead',
|
||||
mediaType: 'voice',
|
||||
requesterKey6: '112233445566',
|
||||
requesterName: 'Alice',
|
||||
);
|
||||
provider.recordMediaTransfer(
|
||||
sessionId: 'deafbead',
|
||||
mediaType: 'voice',
|
||||
requesterKey6: '112233445566',
|
||||
requesterName: 'Alice',
|
||||
);
|
||||
provider.recordMediaTransfer(
|
||||
sessionId: '01020304',
|
||||
mediaType: 'image',
|
||||
requesterKey6: 'a1b2c3d4e5f6',
|
||||
requesterName: 'Bob',
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final voiceDetails = provider.getMessageTransferDetails('voice1');
|
||||
final imageDetails = provider.getMessageTransferDetails('image1');
|
||||
expect(voiceDetails, isNotNull);
|
||||
expect(voiceDetails!.totalTransfers, equals(2));
|
||||
expect(voiceDetails.downloaders.single.requesterName, equals('Alice'));
|
||||
expect(voiceDetails.downloaders.single.transferCount, equals(2));
|
||||
expect(provider.transferCountForSession(voiceSessionId: 'deafbead'), 2);
|
||||
expect(imageDetails?.totalTransfers, equals(1));
|
||||
expect(provider.transferCountForSession(imageSessionId: '01020304'), 1);
|
||||
|
||||
final restoredProvider = MessagesProvider();
|
||||
await restoredProvider.initialize();
|
||||
final restoredVoice = restoredProvider.getMessageTransferDetails(
|
||||
'voice1',
|
||||
);
|
||||
final restoredImage = restoredProvider.getMessageTransferDetails(
|
||||
'image1',
|
||||
);
|
||||
expect(restoredVoice?.totalTransfers, equals(2));
|
||||
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
|
||||
expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
|
||||
});
|
||||
|
||||
test('marks only the selected contact destination as read', () {
|
||||
final provider = MessagesProvider();
|
||||
final alice = buildContact(
|
||||
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
|
||||
type: ContactType.chat,
|
||||
name: 'Alice',
|
||||
);
|
||||
final bob = buildContact(
|
||||
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
|
||||
type: ContactType.chat,
|
||||
name: 'Bob',
|
||||
);
|
||||
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'alice-incoming',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000100,
|
||||
text: 'Alice unread',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: alice.publicKey.sublist(0, 6),
|
||||
),
|
||||
);
|
||||
provider.addMessage(
|
||||
Message(
|
||||
id: 'bob-incoming',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000101,
|
||||
text: 'Bob unread',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: bob.publicKey.sublist(0, 6),
|
||||
),
|
||||
);
|
||||
|
||||
provider.markDestinationAsRead(
|
||||
destinationType: 'contact',
|
||||
contact: alice,
|
||||
);
|
||||
|
||||
final aliceMessage = provider.messages.firstWhere(
|
||||
(message) => message.id == 'alice-incoming',
|
||||
);
|
||||
final bobMessage = provider.messages.firstWhere(
|
||||
(message) => message.id == 'bob-incoming',
|
||||
);
|
||||
|
||||
expect(aliceMessage.isRead, isTrue);
|
||||
expect(bobMessage.isRead, isFalse);
|
||||
expect(provider.unreadCount, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
550
test/providers/sensors_provider_test.dart
Normal file
550
test/providers/sensors_provider_test.dart
Normal file
@@ -0,0 +1,550 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/models/device_info.dart';
|
||||
import 'package:meshcore_sar_app/providers/connection_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
|
||||
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
|
||||
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakeContactsProvider extends ContactsProvider {
|
||||
_FakeContactsProvider(this._contacts);
|
||||
|
||||
final List<Contact> _contacts;
|
||||
|
||||
@override
|
||||
List<Contact> get contacts => _contacts;
|
||||
}
|
||||
|
||||
class _FakeConnectionProvider extends ConnectionProvider {
|
||||
_FakeConnectionProvider({
|
||||
required bool isConnected,
|
||||
Uint8List? publicKey,
|
||||
String? selfName,
|
||||
}) : _isConnected = isConnected,
|
||||
_publicKey = publicKey,
|
||||
_selfName = selfName;
|
||||
|
||||
final bool _isConnected;
|
||||
final Uint8List? _publicKey;
|
||||
final String? _selfName;
|
||||
|
||||
int pingCalls = 0;
|
||||
|
||||
@override
|
||||
DeviceInfo get deviceInfo => DeviceInfo(
|
||||
connectionState: _isConnected
|
||||
? ConnectionState.connected
|
||||
: ConnectionState.disconnected,
|
||||
publicKey: _publicKey,
|
||||
selfName: _selfName,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<PingResult> smartPing({
|
||||
required Uint8List contactPublicKey,
|
||||
required bool hasPath,
|
||||
Function()? onRetryWithFlooding,
|
||||
}) async {
|
||||
pingCalls += 1;
|
||||
return const PingResult(
|
||||
success: true,
|
||||
usedFlooding: false,
|
||||
timedOut: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: false,
|
||||
activeProfileId: 'default',
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> waitUntilLoaded(SensorsProvider provider) async {
|
||||
for (var i = 0; i < 20 && !provider.isLoaded; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
expect(provider.isLoaded, isTrue);
|
||||
}
|
||||
|
||||
Contact buildSensorContact({
|
||||
int firstByte = 0x44,
|
||||
String name = 'WX Station',
|
||||
}) {
|
||||
final publicKey = Uint8List(32);
|
||||
publicKey[0] = firstByte;
|
||||
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.sensor,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: name,
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
}
|
||||
|
||||
Contact buildTelemetrySensorContact() {
|
||||
final publicKey = Uint8List(32);
|
||||
publicKey[0] = 0x55;
|
||||
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
type: ContactType.sensor,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(64),
|
||||
advName: 'Weather Station',
|
||||
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 11.8,
|
||||
humidity: 51,
|
||||
pressure: 921.9,
|
||||
timestamp: DateTime.now(),
|
||||
extraSensorData: const {
|
||||
'__source_channel:temperature': 3,
|
||||
'__source_channel:humidity': 2,
|
||||
'__source_channel:pressure': 2,
|
||||
'illuminance_2': 19150.0,
|
||||
'temperature_2': 1.99,
|
||||
'voltage_2': 5.2,
|
||||
'switch_2': 0,
|
||||
'speed_2': 2.8,
|
||||
'speed_3': 3.7,
|
||||
'uv_2': 1.0,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('metric label overrides persist across reloads', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contact);
|
||||
await provider.setMetricLabel(
|
||||
contact.publicKeyHex,
|
||||
'extra:illuminance_2',
|
||||
'Solar',
|
||||
);
|
||||
|
||||
expect(
|
||||
provider.labelOverrideFor(contact.publicKeyHex, 'extra:illuminance_2'),
|
||||
'Solar',
|
||||
);
|
||||
|
||||
final reloadedProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedProvider);
|
||||
|
||||
expect(
|
||||
reloadedProvider.labelOverrideFor(
|
||||
contact.publicKeyHex,
|
||||
'extra:illuminance_2',
|
||||
),
|
||||
'Solar',
|
||||
);
|
||||
});
|
||||
|
||||
test('metric order persists across reloads', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contact);
|
||||
await provider.moveMetric(
|
||||
contact.publicKeyHex,
|
||||
availableFieldKeys: const ['voltage', 'battery', 'temperature'],
|
||||
oldIndex: 2,
|
||||
newIndex: 0,
|
||||
);
|
||||
|
||||
expect(
|
||||
provider.metricOrderFor(contact.publicKeyHex, const [
|
||||
'voltage',
|
||||
'battery',
|
||||
'temperature',
|
||||
]),
|
||||
const ['temperature', 'voltage', 'battery'],
|
||||
);
|
||||
|
||||
final reloadedProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedProvider);
|
||||
|
||||
expect(
|
||||
reloadedProvider.metricOrderFor(contact.publicKeyHex, const [
|
||||
'voltage',
|
||||
'battery',
|
||||
'temperature',
|
||||
]),
|
||||
const ['temperature', 'voltage', 'battery'],
|
||||
);
|
||||
});
|
||||
|
||||
test('auto refresh minutes persist across reloads', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contact);
|
||||
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1440);
|
||||
|
||||
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
|
||||
|
||||
final reloadedProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedProvider);
|
||||
|
||||
expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440);
|
||||
});
|
||||
|
||||
test('tracked auto refresh history is captured and persisted', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final timestamp = DateTime(2026, 3, 15, 9, 0);
|
||||
final contacts = <Contact>[
|
||||
buildSensorContact().copyWith(
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 12.5,
|
||||
humidity: 54,
|
||||
pressure: 918.2,
|
||||
timestamp: timestamp,
|
||||
extraSensorData: const {'illuminance_2': 150.0},
|
||||
),
|
||||
),
|
||||
];
|
||||
final contactsProvider = _FakeContactsProvider(contacts);
|
||||
final connectionProvider = _FakeConnectionProvider(isConnected: true);
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contacts.first);
|
||||
await provider.setAutoRefreshMinutes(contacts.first.publicKeyHex, 5);
|
||||
|
||||
await provider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
final firstHistory = provider.historyFor(contacts.first.publicKeyHex);
|
||||
expect(firstHistory, hasLength(1));
|
||||
expect(firstHistory.first.values['temperature'], 12.5);
|
||||
expect(firstHistory.first.values['humidity'], 54);
|
||||
expect(firstHistory.first.values['pressure'], 918.2);
|
||||
expect(firstHistory.first.values['extra:illuminance_2'], 150.0);
|
||||
|
||||
await provider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(provider.historyFor(contacts.first.publicKeyHex), hasLength(1));
|
||||
|
||||
contacts[0] = contacts[0].copyWith(
|
||||
telemetry: ContactTelemetry(
|
||||
temperature: 13.1,
|
||||
humidity: 52,
|
||||
pressure: 919.0,
|
||||
timestamp: timestamp.add(const Duration(minutes: 5)),
|
||||
extraSensorData: const {'illuminance_2': 160.0},
|
||||
),
|
||||
);
|
||||
|
||||
await provider.captureTrackedTelemetryHistory(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
|
||||
final updatedHistory = provider.historyFor(contacts.first.publicKeyHex);
|
||||
expect(updatedHistory, hasLength(2));
|
||||
expect(updatedHistory.last.values['temperature'], 13.1);
|
||||
|
||||
final reloadedProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedProvider);
|
||||
expect(reloadedProvider.historyFor(contacts.first.publicKeyHex), hasLength(2));
|
||||
});
|
||||
|
||||
test('watched sensors and preferences are isolated per profile', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: false,
|
||||
activeProfileId: 'default',
|
||||
);
|
||||
final defaultProvider = SensorsProvider();
|
||||
await waitUntilLoaded(defaultProvider);
|
||||
await defaultProvider.addSensor(contact);
|
||||
await defaultProvider.setMetricLabel(
|
||||
contact.publicKeyHex,
|
||||
'voltage',
|
||||
'Default Voltage',
|
||||
);
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
final customProvider = SensorsProvider();
|
||||
await waitUntilLoaded(customProvider);
|
||||
|
||||
expect(customProvider.watchedSensorKeys, isEmpty);
|
||||
expect(
|
||||
customProvider.labelOverrideFor(contact.publicKeyHex, 'voltage'),
|
||||
isNull,
|
||||
);
|
||||
|
||||
await customProvider.addSensor(contact);
|
||||
await customProvider.setMetricLabel(
|
||||
contact.publicKeyHex,
|
||||
'voltage',
|
||||
'Alpha Voltage',
|
||||
);
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: false,
|
||||
activeProfileId: 'default',
|
||||
);
|
||||
final reloadedDefaultProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedDefaultProvider);
|
||||
expect(reloadedDefaultProvider.watchedSensorKeys, [contact.publicKeyHex]);
|
||||
expect(
|
||||
reloadedDefaultProvider.labelOverrideFor(contact.publicKeyHex, 'voltage'),
|
||||
'Default Voltage',
|
||||
);
|
||||
|
||||
ProfileStorageScope.setScope(
|
||||
profilesEnabled: true,
|
||||
activeProfileId: 'alpha',
|
||||
);
|
||||
final reloadedCustomProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedCustomProvider);
|
||||
expect(reloadedCustomProvider.watchedSensorKeys, [contact.publicKeyHex]);
|
||||
expect(
|
||||
reloadedCustomProvider.labelOverrideFor(contact.publicKeyHex, 'voltage'),
|
||||
'Alpha Voltage',
|
||||
);
|
||||
});
|
||||
|
||||
test('watched sensor order persists across reloads', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final first = buildSensorContact(firstByte: 0x44, name: 'First');
|
||||
final second = buildSensorContact(firstByte: 0x45, name: 'Second');
|
||||
final third = buildSensorContact(firstByte: 0x46, name: 'Third');
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(first);
|
||||
await provider.addSensor(second);
|
||||
await provider.addSensor(third);
|
||||
|
||||
await provider.reorderSensors(2, 0);
|
||||
|
||||
expect(provider.watchedSensorKeys, <String>[
|
||||
third.publicKeyHex,
|
||||
first.publicKeyHex,
|
||||
second.publicKeyHex,
|
||||
]);
|
||||
|
||||
final reloadedProvider = SensorsProvider();
|
||||
await waitUntilLoaded(reloadedProvider);
|
||||
|
||||
expect(reloadedProvider.watchedSensorKeys, <String>[
|
||||
third.publicKeyHex,
|
||||
first.publicKeyHex,
|
||||
second.publicKeyHex,
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'unsupported auto refresh minutes normalize to nearest option',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contact);
|
||||
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1);
|
||||
|
||||
expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5);
|
||||
},
|
||||
);
|
||||
|
||||
test('refreshDueSensors respects per-contact interval', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildSensorContact();
|
||||
final contactsProvider = _FakeContactsProvider(<Contact>[contact]);
|
||||
final connectionProvider = _FakeConnectionProvider(isConnected: true);
|
||||
final start = DateTime(2026, 3, 15, 9, 0);
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contact);
|
||||
await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 1);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start.add(const Duration(minutes: 4)),
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 1);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start.add(const Duration(minutes: 5)),
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 2);
|
||||
});
|
||||
|
||||
test('refreshDueSensors refreshes self every 30 seconds', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final selfKey = Uint8List(32)..[0] = 0x66;
|
||||
final contactsProvider = ContactsProvider();
|
||||
await contactsProvider.initialize(devicePublicKey: selfKey);
|
||||
final connectionProvider = _FakeConnectionProvider(
|
||||
isConnected: true,
|
||||
publicKey: selfKey,
|
||||
selfName: 'My Device',
|
||||
);
|
||||
final start = DateTime(2026, 3, 22, 9, 0);
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start,
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 1);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start.add(const Duration(seconds: 30)),
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 2);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start.add(const Duration(seconds: 59)),
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 2);
|
||||
|
||||
await provider.refreshDueSensors(
|
||||
now: start.add(const Duration(minutes: 1)),
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
);
|
||||
expect(connectionProvider.pingCalls, 3);
|
||||
});
|
||||
|
||||
test(
|
||||
'addSensor includes available extra telemetry fields by default',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final contact = buildTelemetrySensorContact();
|
||||
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(contact);
|
||||
|
||||
expect(
|
||||
provider.visibleFieldsFor(contact.publicKeyHex),
|
||||
containsAll(<String>{
|
||||
'temperature',
|
||||
'humidity',
|
||||
'pressure',
|
||||
'extra:illuminance_2',
|
||||
'extra:temperature_2',
|
||||
'extra:voltage_2',
|
||||
'extra:switch_2',
|
||||
'extra:speed_2',
|
||||
'extra:speed_3',
|
||||
'extra:uv_2',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('selfContact includes stored self telemetry', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final selfKey = Uint8List(32)..[0] = 0x66;
|
||||
final contactsProvider = ContactsProvider();
|
||||
await contactsProvider.initialize(devicePublicKey: selfKey);
|
||||
contactsProvider.updateTelemetry(
|
||||
selfKey.sublist(0, 6),
|
||||
CayenneLppParser.createTemperatureData(19.5, channel: 1),
|
||||
);
|
||||
|
||||
final connectionProvider = _FakeConnectionProvider(
|
||||
isConnected: true,
|
||||
publicKey: selfKey,
|
||||
selfName: 'My Device',
|
||||
);
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
|
||||
final selfContact = provider.selfContact(
|
||||
contactsProvider,
|
||||
connectionProvider,
|
||||
);
|
||||
|
||||
expect(selfContact, isNotNull);
|
||||
expect(selfContact!.telemetry, isNotNull);
|
||||
expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1));
|
||||
});
|
||||
|
||||
test(
|
||||
'displaySelfKey still returns self when watched sensors exist',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final selfKey = Uint8List(32)..[0] = 0x66;
|
||||
final sensor = buildSensorContact(firstByte: 0x44, name: 'Weather');
|
||||
final contactsProvider = ContactsProvider();
|
||||
await contactsProvider.initialize(devicePublicKey: selfKey);
|
||||
|
||||
final connectionProvider = _FakeConnectionProvider(
|
||||
isConnected: true,
|
||||
publicKey: selfKey,
|
||||
selfName: 'My Device',
|
||||
);
|
||||
final provider = SensorsProvider();
|
||||
await waitUntilLoaded(provider);
|
||||
await provider.addSensor(sensor);
|
||||
|
||||
expect(
|
||||
provider.displaySelfKey(
|
||||
contactsProvider: contactsProvider,
|
||||
connectionProvider: connectionProvider,
|
||||
),
|
||||
selfKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user