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:
Janez Troha
2026-09-04 16:53:52 +02:00
commit 7924803351
516 changed files with 207265 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart';
void main() {
test('hash channels expose deterministic psk_base64', () {
final channel = Channel.create(index: 3, name: '#ops');
expect(channel.isHashChannel, isTrue);
expect(channel.pskBase64, 'O2RN43fDLHh5NgWiWqkVvw==');
expect(
Channel.pskBase64ForHashChannelName('#ops'),
'O2RN43fDLHh5NgWiWqkVvw==',
);
});
test('normal channels reject derived hashtag psk export helper', () {
expect(
() => Channel.pskBase64ForHashChannelName('ops'),
throwsArgumentError,
);
});
test('normal channels still expose their stored psk_base64', () {
final channel = Channel.create(
index: 4,
name: 'ops',
explicitSecret: Uint8List.fromList(List<int>.generate(16, (i) => i)),
);
expect(channel.isHashChannel, isFalse);
expect(channel.pskBase64, 'AAECAwQFBgcICQoLDA0ODw==');
});
}

View File

@@ -0,0 +1,172 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
void main() {
Contact buildContact({
required int signedPathLen,
required Uint8List outPath,
}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (index) => index)),
type: ContactType.chat,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath,
advName: 'Route Contact',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
group('ContactRouteCodec.parse', () {
test('parses 1-byte hop routes', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
expect(route.hashSize, 1);
expect(route.hopCount, 3);
expect(route.encodedPathLen, 0x03);
expect(route.canonicalText, 'AA,BB,CC');
expect(route.pathBytes, [0xAA, 0xBB, 0xCC]);
});
test('parses 2-byte hop routes', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
expect(route.hashSize, 2);
expect(route.hopCount, 2);
expect(route.encodedPathLen, 0x42);
expect(route.canonicalText, 'AABB,CCDD');
expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
});
test('parses 3-byte hop routes', () {
final route = ContactRouteCodec.parse('AABBCC,DDEEFF');
expect(route.hashSize, 3);
expect(route.hopCount, 2);
expect(route.encodedPathLen, 0x82);
expect(route.signedEncodedPathLen, -126);
expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
});
test('accepts colon-separated hops and normalizes output', () {
final route = ContactRouteCodec.parse('AA:BB,CC:DD');
expect(route.hashSize, 2);
expect(route.canonicalText, 'AABB,CCDD');
});
test('rejects mixed hop widths', () {
expect(
() => ContactRouteCodec.parse('AA,AABB'),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects routes that do not match the configured hash size', () {
expect(
() => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects invalid tokens', () {
expect(
() => ContactRouteCodec.parse('AA,XYZ'),
throwsA(isA<ContactRouteFormatException>()),
);
expect(
() => ContactRouteCodec.parse('AAA'),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects routes over 64 bytes', () {
final tooLong = List.filled(22, 'AABBCC').join(',');
expect(
() => ContactRouteCodec.parse(tooLong),
throwsA(isA<ContactRouteFormatException>()),
);
});
});
group('Contact route helpers', () {
test('interprets signed 3-byte descriptors as valid routes', () {
final outPath = Uint8List(64)
..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
final contact = buildContact(signedPathLen: -126, outPath: outPath);
expect(contact.routeHasPath, isTrue);
expect(contact.routeHashSize, 3);
expect(contact.routeHopCount, 2);
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
expect(contact.routeSupportsLegacyRawTransport, isTrue);
});
test('treats -1 as unknown route', () {
final contact = buildContact(signedPathLen: -1, outPath: Uint8List(0));
expect(contact.routeHasPath, isFalse);
expect(contact.routeSummary, 'Flood/Unknown');
});
});
group('ContactRouteCodec.toLegacyRawPath', () {
test('zero-hop direct (0x40) → empty legacy path', () {
final legacy = ContactRouteCodec.toLegacyRawPath(0x40, Uint8List(0));
expect(legacy.length, 0);
expect(legacy.path, isEmpty);
});
test('1-hop 2-byte hash (0x41) → first byte of the hash', () {
final legacy = ContactRouteCodec.toLegacyRawPath(
0x41,
Uint8List.fromList([0x02, 0x62]),
);
expect(legacy.length, 1);
expect(legacy.path, [0x02]);
});
test('2-hop 2-byte hash (0x42) → first byte of each hash', () {
final legacy = ContactRouteCodec.toLegacyRawPath(
0x42,
Uint8List.fromList([0xAA, 0xBB, 0xCC, 0xDD]),
);
expect(legacy.length, 2);
expect(legacy.path, [0xAA, 0xCC]);
});
test('signed 3-byte descriptor (-126 / 0x82) down-samples each hop', () {
final outPath = Uint8List(64)
..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
final legacy = ContactRouteCodec.toLegacyRawPath(-126, outPath);
expect(legacy.length, 2);
expect(legacy.path, [0xAA, 0xDD]);
});
test('legacy 1-byte hops (< 0x40) pass through trimmed', () {
final legacy = ContactRouteCodec.toLegacyRawPath(
0x03,
Uint8List.fromList([0xAA, 0xBB, 0xCC, 0x00, 0x00]),
);
expect(legacy.length, 3);
expect(legacy.path, [0xAA, 0xBB, 0xCC]);
});
test('unknown descriptor (0xFF) collapses to direct', () {
final legacy = ContactRouteCodec.toLegacyRawPath(-1, Uint8List(0));
expect(legacy.length, 0);
expect(legacy.path, isEmpty);
});
});
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/custom_map_config.dart';
void main() {
test('displayBounds stays within valid LatLng limits for tall images', () {
const config = CustomMapConfig(
filePath: '/tmp/map.png',
displayName: 'Tall Map',
mapId: 'map-id',
imageWidth: 2400,
imageHeight: 3213,
);
expect(config.displayBounds.north, lessThanOrEqualTo(90));
expect(config.displayBounds.south, greaterThanOrEqualTo(-90));
expect(config.displayBounds.east, lessThanOrEqualTo(180));
expect(config.displayBounds.west, greaterThanOrEqualTo(-180));
});
test('display point conversion preserves stored coordinates', () {
const config = CustomMapConfig(
filePath: '/tmp/map.png',
displayName: 'Tall Map',
mapId: 'map-id',
imageWidth: 2400,
imageHeight: 3213,
);
const storedPoint = LatLng(1600, 1200);
final displayPoint = config.toDisplayPoint(storedPoint);
final roundTrip = config.fromDisplayPoint(displayPoint);
expect(roundTrip.latitude, closeTo(storedPoint.latitude, 0.001));
expect(roundTrip.longitude, closeTo(storedPoint.longitude, 0.001));
});
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
void main() {
test('drops impossible transmit estimate for received messages', () {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000,
senderToReceiptMs: 4200,
),
isNull,
);
});
test(
'keeps close transmit estimate despite second-level timestamp rounding',
() {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 1800,
senderToReceiptMs: 900,
),
1800,
);
},
);
test('round trips reception details json', () {
final details = MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
rssiDbm: -92,
snrDb: 7.5,
pathBytes: const [0xAA, 0xBB, 0xCC],
senderToReceiptMs: 4200,
estimatedTransmitMs: 1800,
postTransmitDelayMs: 2400,
);
final decoded = MessageReceptionDetails.fromJson(details.toJson());
expect(decoded, isNotNull);
expect(decoded!.rssiDbm, -92);
expect(decoded.snrDb, 7.5);
expect(decoded.pathBytesHex, 'aa:bb:cc');
expect(decoded.senderToReceiptMs, 4200);
expect(decoded.estimatedTransmitMs, 1800);
expect(decoded.postTransmitDelayMs, 2400);
});
}

View 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);
}

View 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,
);
});
});
}

View 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,
);
});
});
}

View 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');
},
);
}

File diff suppressed because it is too large Load Diff

View 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));
});
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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);
});
});
}

View 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),
);
});
});
}

View 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));
});
});
}

View 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);
});
});
}

View 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);
}

File diff suppressed because it is too large Load Diff

View 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));
});
});
}

View 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(),
);
},
);
}

View File

@@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/channel.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
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 image_provider;
import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/screens/contacts_tab.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/contact_grouping.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected})
: _isConnected = isConnected;
final bool _isConnected;
@override
device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo(
connectionState: _isConnected
? device_info.ConnectionState.connected
: device_info.ConnectionState.disconnected,
);
}
class _FakeMessagesProvider extends MessagesProvider {
@override
bool get isInitialized => true;
}
class _FakeDrawingProvider extends DrawingProvider {
@override
bool get isInitialized => true;
}
void main() {
String? clipboardText;
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: false,
activeProfileId: 'default',
);
clipboardText = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
switch (call.method) {
case 'Clipboard.setData':
clipboardText =
(call.arguments as Map<dynamic, dynamic>)['text'] as String?;
return null;
case 'Clipboard.getData':
return <String, dynamic>{'text': clipboardText};
}
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
});
Contact buildChannel({required String name, required int channelIndex}) {
final publicKey = Uint8List(32);
publicKey[0] = 0xFF;
publicKey[1] = channelIndex;
return Contact(
publicKey: publicKey,
type: ContactType.channel,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact buildRepeater({required int seed, required String name}) {
final publicKey = Uint8List(32);
publicKey[0] = seed;
publicKey[1] = seed + 1;
return Contact(
publicKey: publicKey,
type: ContactType.repeater,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46056000 + seed,
advLon: 14505000 + seed,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact buildSensor({required int seed, required String name}) {
final publicKey = Uint8List(32);
publicKey[0] = seed;
publicKey[1] = seed + 1;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46056000 + seed,
advLon: 14505000 + seed,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpContactsTab(
WidgetTester tester, {
List<Contact> contacts = const [],
List<SavedContactGroup> savedGroups = const [],
ConnectionProvider? connectionProvider,
}) async {
final contactsProvider = ContactsProvider();
final resolvedConnectionProvider =
connectionProvider ?? ConnectionProvider();
final messagesProvider = _FakeMessagesProvider();
final drawingProvider = _FakeDrawingProvider();
final channelsProvider = ChannelsProvider();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = image_provider.ImageProvider();
final appProvider = AppProvider(
connectionProvider: resolvedConnectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
for (final contact in contacts) {
contactsProvider.addOrUpdateContact(contact);
}
if (savedGroups.isNotEmpty) {
await contactsProvider.replaceAutoGroupsForSection(
'repeaters',
savedGroups,
);
}
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ContactsProvider>(
create: (_) => contactsProvider,
),
ChangeNotifierProvider<ConnectionProvider>(
create: (_) => resolvedConnectionProvider,
),
ChangeNotifierProvider<MessagesProvider>(
create: (_) => messagesProvider,
),
ChangeNotifierProvider(create: (_) => MapProvider()),
ChangeNotifierProvider<AppProvider>(create: (_) => appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ContactsTab()),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('private channel activity card shows delete action in sheet', (
tester,
) async {
await pumpContactsTab(
tester,
contacts: [buildChannel(name: 'Ops', channelIndex: 3)],
);
expect(find.text('Ops'), findsOneWidget);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Export psk_base64'), findsNothing);
expect(find.text('Delete Channel'), findsOneWidget);
await tester.tap(find.text('Delete Channel'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsWidgets);
expect(
find.text(
'Are you sure you want to delete channel "Ops"? This action cannot be undone.',
),
findsOneWidget,
);
});
testWidgets('hash channel activity card exports psk_base64', (tester) async {
await pumpContactsTab(
tester,
contacts: [buildChannel(name: '#ops', channelIndex: 3)],
);
expect(find.text('#ops'), findsOneWidget);
await tester.tap(find.text('#ops'));
await tester.pumpAndSettle();
expect(find.text('Export psk_base64'), findsOneWidget);
await tester.tap(find.text('Export psk_base64'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(clipboardText, Channel.pskBase64ForHashChannelName('#ops'));
expect(find.text('psk_base64 copied to clipboard'), findsOneWidget);
});
testWidgets('repeaters show Others group when multiple groups exist', (
tester,
) async {
final repeaters = [
buildRepeater(seed: 10, name: 'AL-1'),
buildRepeater(seed: 11, name: 'AL-2'),
buildRepeater(seed: 12, name: 'AL-3'),
buildRepeater(seed: 13, name: 'AL-4'),
buildRepeater(seed: 20, name: 'BR-1'),
buildRepeater(seed: 21, name: 'BR-2'),
buildRepeater(seed: 22, name: 'BR-3'),
buildRepeater(seed: 23, name: 'BR-4'),
buildRepeater(seed: 30, name: 'Lone Relay'),
];
final inferredGroups = ContactGrouping.inferGroups(repeaters);
final savedGroups = inferredGroups
.map(
(group) => SavedContactGroup(
id: 'repeaters_${group.key}',
sectionKey: 'repeaters',
label: group.label,
query: group.label,
createdAt: DateTime(2026, 3, 13, 10),
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await pumpContactsTab(
tester,
contacts: repeaters,
savedGroups: savedGroups,
);
expect(find.text('AL-'), findsOneWidget);
expect(find.text('BR-'), findsOneWidget);
expect(find.text('Others'), findsOneWidget);
expect(find.text('Lone Relay'), findsNothing);
});
testWidgets('repeaters stay flat when only one group exists', (tester) async {
final repeaters = [
buildRepeater(seed: 40, name: 'AL-1'),
buildRepeater(seed: 41, name: 'AL-2'),
buildRepeater(seed: 42, name: 'AL-3'),
buildRepeater(seed: 43, name: 'AL-4'),
buildRepeater(seed: 50, name: 'Lone Relay'),
];
final inferredGroups = ContactGrouping.inferGroups(repeaters);
final savedGroups = inferredGroups
.map(
(group) => SavedContactGroup(
id: 'repeaters_${group.key}',
sectionKey: 'repeaters',
label: group.label,
query: group.label,
createdAt: DateTime(2026, 3, 13, 10),
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await pumpContactsTab(
tester,
contacts: repeaters,
savedGroups: savedGroups,
);
expect(find.text('AL-'), findsOneWidget);
expect(find.text('Others'), findsNothing);
expect(find.text('Lone Relay'), findsOneWidget);
});
testWidgets('sensor contacts render in their own section', (tester) async {
await pumpContactsTab(
tester,
contacts: [buildSensor(seed: 60, name: 'WX Station')],
);
expect(find.text('Sensors'), findsWidgets);
expect(find.text('WX Station'), findsOneWidget);
});
testWidgets(
'contacts sections do not show sensor or repeater discovery actions',
(tester) async {
await pumpContactsTab(
tester,
contacts: [
buildRepeater(seed: 70, name: 'Relay 1'),
buildSensor(seed: 71, name: 'WX Station'),
],
connectionProvider: _FakeConnectionProvider(isConnected: true),
);
expect(find.byTooltip('Discover repeaters'), findsNothing);
expect(find.byTooltip('Discover sensors'), findsNothing);
expect(find.byIcon(Icons.radar), findsNothing);
},
);
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
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/screens/device_config_screen.dart';
import 'package:provider/provider.dart';
class _FakeConnectionProvider extends ChangeNotifier
implements ConnectionProvider {
_FakeConnectionProvider(this._deviceInfo);
device_info.DeviceInfo _deviceInfo;
@override
device_info.DeviceInfo get deviceInfo => _deviceInfo;
void updateDeviceInfo(device_info.DeviceInfo nextDeviceInfo) {
_deviceInfo = nextDeviceInfo;
notifyListeners();
}
@override
Future<Map<String, String>> getCustomVars() async => const {};
@override
Future<void> getBatteryAndStorage() async {}
@override
Future<void> getAutoaddConfig() async {}
@override
Future<void> getAllowedRepeatFreq() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
Future<void> _pumpDeviceConfigScreen(
WidgetTester tester, {
required ConnectionProvider connectionProvider,
}) async {
tester.view.physicalSize = const Size(1440, 4000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
),
ChangeNotifierProvider<ChannelsProvider>(
create: (_) => ChannelsProvider()..initializePublicChannel(),
),
ChangeNotifierProvider<ContactsProvider>(
create: (_) => ContactsProvider(),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const DeviceConfigScreen(),
),
),
);
await tester.pump();
}
void main() {
testWidgets(
'matches a preset when bandwidth arrives as raw Hz',
(tester) async {
final connectionProvider = _FakeConnectionProvider(
device_info.DeviceInfo(
connectionState: device_info.ConnectionState.connected,
radioFreq: 869618,
radioBw: 62500,
radioSf: 8,
radioCr: 8,
),
);
await _pumpDeviceConfigScreen(
tester,
connectionProvider: connectionProvider,
);
expect(find.byKey(const ValueKey('eu_uk_narrow')), findsOneWidget);
expect(find.text('EU/UK (Narrow)'), findsWidgets);
},
);
testWidgets(
're-syncs the preset dropdown when device info changes',
(tester) async {
final connectionProvider = _FakeConnectionProvider(
device_info.DeviceInfo(
connectionState: device_info.ConnectionState.connected,
),
);
await _pumpDeviceConfigScreen(
tester,
connectionProvider: connectionProvider,
);
expect(find.byKey(const ValueKey('custom')), findsOneWidget);
connectionProvider.updateDeviceInfo(
device_info.DeviceInfo(
connectionState: device_info.ConnectionState.connected,
radioFreq: 869618,
radioBw: 62500,
radioSf: 8,
radioCr: 8,
),
);
await tester.pump();
expect(find.byKey(const ValueKey('eu_uk_narrow')), findsOneWidget);
},
);
}

View File

@@ -0,0 +1,206 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/device_info.dart' as device_info;
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/screens/discovery_screen.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected})
: _isConnected = isConnected;
final bool _isConnected;
final List<int> discoveredAdvertTypes = <int>[];
@override
device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo(
connectionState: _isConnected
? device_info.ConnectionState.connected
: device_info.ConnectionState.disconnected,
);
@override
Future<void> discoverNodeType({
required int advertType,
bool prefixOnly = false,
int since = 0,
}) async {
discoveredAdvertTypes.add(advertType);
}
}
Future<void> _pumpDiscoveryScreen(
WidgetTester tester, {
ContactsProvider? contactsProvider,
ConnectionProvider? connectionProvider,
DiscoveryScreen screen = const DiscoveryScreen(),
}) async {
final resolvedContactsProvider = contactsProvider ?? ContactsProvider();
final resolvedConnectionProvider =
connectionProvider ?? _FakeConnectionProvider(isConnected: true);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ContactsProvider>.value(
value: resolvedContactsProvider,
),
ChangeNotifierProvider<ConnectionProvider>.value(
value: resolvedConnectionProvider,
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: screen,
),
),
);
await tester.pumpAndSettle();
}
Uint8List _publicKey(int seed) {
final bytes = Uint8List(32);
bytes[0] = seed;
bytes[1] = seed + 1;
bytes[2] = seed + 2;
return bytes;
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'discovery actions are shown in the summary card instead of overflow menu',
(tester) async {
tester.view.physicalSize = const Size(375, 812);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final connectionProvider = _FakeConnectionProvider(isConnected: true);
await _pumpDiscoveryScreen(
tester,
connectionProvider: connectionProvider,
);
expect(tester.takeException(), isNull);
expect(find.text('Discover repeaters'), findsOneWidget);
expect(find.text('Discover sensors'), findsOneWidget);
expect(find.text('Resolve all'), findsOneWidget);
expect(find.text('Clear all'), findsOneWidget);
expect(find.text('Search discovered nodes'), findsNothing);
expect(find.byIcon(Icons.more_vert), findsNothing);
expect(
find.text(
'Use the discovery actions above to find repeaters and sensors on the mesh.',
),
findsOneWidget,
);
await tester.tap(find.text('Discover repeaters'));
await tester.pump();
expect(connectionProvider.discoveredAdvertTypes, [2]);
expect(find.text('Repeater discovery sent'), findsOneWidget);
},
);
testWidgets('auto discovery can trigger repeater discovery on open', (
tester,
) async {
final connectionProvider = _FakeConnectionProvider(isConnected: true);
await _pumpDiscoveryScreen(
tester,
connectionProvider: connectionProvider,
screen: const DiscoveryScreen(autoDiscoverRepeatersOnOpen: true),
);
expect(connectionProvider.discoveredAdvertTypes, [2]);
expect(find.text('Repeater discovery sent'), findsOneWidget);
});
testWidgets('search and inline type filters narrow discovered nodes', (
tester,
) async {
tester.view.physicalSize = const Size(390, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final contactsProvider = ContactsProvider()
..addOrUpdatePendingAdvertMetadata(
publicKey: _publicKey(0x10),
typeValue: 2,
advName: 'Relay Alpha',
)
..addOrUpdatePendingAdvertMetadata(
publicKey: _publicKey(0x20),
typeValue: 4,
advName: 'WX Station',
)
..addOrUpdatePendingAdvertMetadata(
publicKey: _publicKey(0x30),
typeValue: 3,
advName: 'Ops Room',
);
await _pumpDiscoveryScreen(
tester,
contactsProvider: contactsProvider,
connectionProvider: _FakeConnectionProvider(isConnected: true),
);
expect(find.text('Search discovered nodes'), findsOneWidget);
expect(find.text('All'), findsOneWidget);
expect(find.text('Repeaters'), findsOneWidget);
expect(find.text('Sensors'), findsOneWidget);
expect(find.text('Others'), findsOneWidget);
expect(find.text('Relay Alpha'), findsOneWidget);
expect(find.text('WX Station'), findsOneWidget);
expect(find.text('Ops Room'), findsOneWidget);
await tester.tap(find.text('Sensors'));
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsNothing);
expect(find.text('WX Station'), findsOneWidget);
expect(find.text('Ops Room'), findsNothing);
expect(find.text('Discovered nodes (1/3)'), findsOneWidget);
await tester.tap(find.text('Others'));
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsNothing);
expect(find.text('WX Station'), findsNothing);
expect(find.text('Ops Room'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'relay');
await tester.pumpAndSettle();
expect(find.text('No matches'), findsOneWidget);
await tester.tap(find.text('All'));
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsOneWidget);
expect(find.text('Ops Room'), findsNothing);
await tester.enterText(find.byType(TextField), 'ops');
await tester.pumpAndSettle();
expect(find.text('Relay Alpha'), findsNothing);
expect(find.text('WX Station'), findsNothing);
expect(find.text('Ops Room'), findsOneWidget);
expect(find.text('Discovered nodes (1/3)'), findsOneWidget);
});
}

View File

@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/channel.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
import 'package:provider/provider.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
Widget _testApp(Widget child, {ChannelsProvider? channelsProvider}) {
final app = MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
);
if (channelsProvider == null) {
return app;
}
return ChangeNotifierProvider.value(value: channelsProvider, child: app);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() {
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
});
testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('No packets for this filter'), findsOneWidget);
expect(find.text('Quiet'), findsOneWidget);
});
testWidgets('updates summary and stream for incoming live traffic', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
logs.addAll([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 13.5,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 3)),
direction: PacketDirection.tx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 2)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]);
refresh.value += 1;
await tester.pump();
expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 1'), findsOneWidget);
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
expect(find.text('-84 dBm'), findsOneWidget);
expect(find.textContaining('Hash:'), findsOneWidget);
expect(
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
findsOneWidget,
);
expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget);
});
testWidgets('clear live view only resets transient screen state', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
logs.add(
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
),
);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('FLOOD RESPONSE'), findsOneWidget);
await tester.tap(find.byTooltip('Clear live view'));
await tester.pump();
expect(find.text('No packets for this filter'), findsOneWidget);
now = now.add(const Duration(seconds: 2));
logs.add(
_log(
timestamp: now,
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
);
logs.add(
_log(
timestamp: now,
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
);
refresh.value += 1;
await tester.pump();
expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
});
testWidgets('shows known channel name for group traffic', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
final channel = Channel.create(index: 3, name: '#ops');
final channelsProvider = ChannelsProvider()
..initializePublicChannel()
..addOrUpdateChannelObject(channel);
final now = DateTime(2026, 3, 12, 12, 0, 0);
logs.add(
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: [
..._multiHopRaw(hops: [0xC0, 0x10], payloadType: 0x05),
channel.hashByte,
],
responseCode: 0x88,
),
);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
channelsProvider: channelsProvider,
),
);
expect(find.text('#ops'), findsOneWidget);
expect(find.text('Channel: #ops (${channel.hashHex})'), findsOneWidget);
expect(find.text('FLOOD GROUP_TEXT'), findsNothing);
});
testWidgets('shows packet type help sheet from the app bar', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
final now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
await tester.tap(find.byTooltip('Packet type help'));
await tester.pumpAndSettle();
expect(find.text('Packet Types'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('FLOOD RETURNED_PATH'),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
expect(find.text('FLOOD RETURNED_PATH'), findsOneWidget);
expect(
find.textContaining('stores that returned path as the peer\'s direct out-path'),
findsOneWidget,
);
await tester.scrollUntilVisible(
find.text('FLOOD CONTROL'),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.pumpAndSettle();
expect(find.text('FLOOD CONTROL'), findsOneWidget);
});
testWidgets('opens public stats from the app bar', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
final now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
_testApp(
LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
await tester.tap(find.byTooltip('View public stats'));
await tester.pump();
expect(launchedUrls, ['https://mcstats.dz0ny.dev']);
});
testWidgets('summary metrics expand across wide layouts', (tester) async {
tester.view.physicalSize = const Size(1200, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final now = DateTime(2026, 3, 12, 12, 0, 0);
final logs = [
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
snrDb: 9.5,
rssiDbm: -82,
),
];
await tester.pumpWidget(
_testApp(LiveTrafficScreen(logReader: () => logs, now: () => now)),
);
expect(
tester
.getSize(
find.byKey(const ValueKey('liveTrafficMetric:rxPackets')),
)
.width >
200,
isTrue,
);
expect(
tester
.getSize(find.byKey(const ValueKey('liveTrafficMetric:multihop')))
.width >
200,
isTrue,
);
});
}

View File

@@ -0,0 +1,435 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.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/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/screens/messages_tab.dart';
import 'package:meshcore_sar_app/services/message_destination_preferences.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:typed_data';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[0] = secondByte;
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('marks public channel as read after 5 seconds of viewing', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
messagesProvider.addMessage(
Message(
id: 'public-unread',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Unread on public channel',
receivedAt: DateTime.now(),
channelIdx: 0,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pump();
expect(messagesProvider.unreadCount, 1);
await tester.pump(const Duration(seconds: 4));
expect(messagesProvider.unreadCount, 1);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(messagesProvider.unreadCount, 0);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
testWidgets('shows mention overlay above composer and inserts selection', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
]);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.byType(TextField).first);
await tester.enterText(find.byType(TextField).first, '@t');
await tester.pumpAndSettle();
expect(find.text('Tim'), findsOneWidget);
expect(find.text('Slane'), findsNothing);
await tester.tap(find.text('Tim'));
await tester.pumpAndSettle();
expect(find.text('@[Tim] '), findsOneWidget);
expect(find.text('Tim'), findsNothing);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
testWidgets(
'locks messages tab to the configured channel and removes the selector affordance',
(tester) async {
final lockedChannel = buildContact(
name: '#ops',
type: ContactType.channel,
secondByte: 2,
);
SharedPreferences.setMockInitialValues({
'message_locked_destination_enabled': true,
'message_locked_destination_type':
MessageDestinationPreferences.destinationTypeChannel,
'message_locked_recipient_public_key': lockedChannel.publicKeyHex,
});
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([lockedChannel]);
messagesProvider.addMessage(
Message(
id: 'public-message',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000200,
text: 'Public chatter',
receivedAt: DateTime.now(),
channelIdx: 0,
),
);
messagesProvider.addMessage(
Message(
id: 'locked-message',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000201,
text: 'Ops chatter',
receivedAt: DateTime.now(),
channelIdx: 2,
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Ops chatter'), findsOneWidget);
expect(find.text('Public chatter'), findsNothing);
expect(
find.byKey(const ValueKey('messages_composer_destination_locked')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('messages_composer_destination_selector')),
findsNothing,
);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
},
);
testWidgets(
'contact navigation temporarily overrides the lock while keeping the selector hidden',
(tester) async {
final directContact = buildContact(
name: 'Tim',
type: ContactType.chat,
secondByte: 9,
);
SharedPreferences.setMockInitialValues({
'message_locked_destination_enabled': true,
'message_locked_destination_type':
MessageDestinationPreferences.destinationTypeChannel,
});
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([directContact]);
messagesProvider.addMessage(
Message(
id: 'public-message',
messageType: MessageType.channel,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000300,
text: 'Public chatter',
receivedAt: DateTime.now(),
channelIdx: 0,
),
);
messagesProvider.addMessage(
Message(
id: 'direct-message',
messageType: MessageType.contact,
senderPublicKeyPrefix: directContact.publicKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000301,
text: 'Direct chatter',
receivedAt: DateTime.now(),
),
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeContact,
recipientPublicKeyHex: directContact.publicKeyHex,
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Direct chatter'), findsOneWidget);
expect(find.text('Public chatter'), findsNothing);
expect(
find.byKey(const ValueKey('messages_composer_destination_locked')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('messages_composer_destination_selector')),
findsNothing,
);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
},
);
}

View File

@@ -0,0 +1,122 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() {
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,
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'illuminance_2': 500.0,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
}
testWidgets('metric selector item shows channel-specific preview', (
tester,
) async {
final contact = buildSensorContact();
final option = sensorMetricOptionsFor(contact, labelOverrides: const {})
.firstWhere(
(entry) => entry.key.contains('illuminance') && entry.channel == 2,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorMetricSelectorItem(
option: option,
visible: true,
span: 1,
canMoveUp: false,
canMoveDown: false,
onToggle: (_) {},
onRename: () {},
onSpanChanged: (_) {},
),
),
),
);
await tester.pump();
expect(
find.byKey(ValueKey('sensor_selector_metric_${option.key}')),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
testWidgets('add sensor sheet keeps close action available when scrolled', (
tester,
) async {
tester.view.physicalSize = const Size(320, 480);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
var didClose = false;
final candidates = List<Contact>.generate(
20,
(index) =>
buildSensorContact(firstByte: index + 1, name: 'Sensor ${index + 1}'),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: AddSensorSheet(
candidates: candidates,
onSelect: (_) async {},
onClose: () {
didClose = true;
},
),
),
),
);
await tester.pump();
final closeButton = find.byTooltip('Close');
expect(closeButton, findsOneWidget);
await tester.drag(find.byType(ListView).last, const Offset(0, -600));
await tester.pump();
expect(closeButton, findsOneWidget);
await tester.tap(closeButton);
await tester.pump();
expect(didClose, isTrue);
});
}

View File

@@ -0,0 +1,84 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/bthome_met_history.dart';
void main() {
test('parses a valid BTHome MET history response', () {
final parsed = BTHomeMetHistoryParser.parse('1,0,4,11.2,11.8,12.1,12.3');
expect(parsed.measurement, BTHomeMetMeasurement.temperature);
expect(parsed.page, 0);
expect(parsed.values, <double>[11.2, 11.8, 12.1, 12.3]);
expect(parsed.latest, 12.3);
expect(parsed.minimum, 11.2);
expect(parsed.maximum, 12.3);
});
test('rejects malformed BTHome MET history counts', () {
expect(
() => BTHomeMetHistoryParser.parse('2,0,3,41,42'),
throwsA(isA<BTHomeMetHistoryFormatException>()),
);
});
test('detects available BTHome MET measurements from telemetry', () {
final contact = Contact(
publicKey: Uint8List(32),
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
telemetry: ContactTelemetry(
temperature: 20.1,
humidity: 52,
extraSensorData: const {
'generic_sensor_1': 1,
'speed_2': 3.1,
'gust_2': 4.8,
'rain_2': 12.3,
},
timestamp: DateTime(2026, 3, 21, 12),
),
);
expect(bTHomeMetMeasurementsForContact(contact), <BTHomeMetMeasurement>[
BTHomeMetMeasurement.temperature,
BTHomeMetMeasurement.humidity,
BTHomeMetMeasurement.windSpeed,
BTHomeMetMeasurement.gust,
BTHomeMetMeasurement.rain,
]);
expect(supportsBTHomeMetHistory(contact), isTrue);
});
test('does not enable BTHome MET history without channel 1 capability', () {
final contact = Contact(
publicKey: Uint8List(32),
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
telemetry: ContactTelemetry(
temperature: 20.1,
humidity: 52,
extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3},
timestamp: DateTime(2026, 3, 21, 12),
),
);
expect(bTHomeMetMeasurementsForContact(contact), isEmpty);
expect(supportsBTHomeMetHistory(contact), isFalse);
});
}

View File

@@ -0,0 +1,682 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_client/meshcore_client.dart';
void main() {
group('CayenneLppParser - GPS Codec Tests', () {
test('GPS encoding uses correct 3-byte signed BE format', () {
// Test coordinates (Ljubljana, Slovenia)
const double lat = 46.0569;
const double lon = 14.5058;
const double alt = 295.0;
final encoded = CayenneLppParser.createGpsData(
latitude: lat,
longitude: lon,
altitude: alt,
channel: 0,
);
// Expected format (Standard Cayenne LPP):
// [0] = channel (0)
// [1] = type (136 = 0x88 = lppGps)
// [2-4] = lat as 24-bit signed BE
// [5-7] = lon as 24-bit signed BE
// [8-10] = alt as 24-bit signed BE
expect(encoded.length, equals(11));
expect(encoded[0], equals(0)); // channel
expect(encoded[1], equals(MeshCoreConstants.lppGps)); // type 0x88
// Verify big-endian encoding (3 bytes each)
final latEncoded = (encoded[2] << 16) | (encoded[3] << 8) | encoded[4];
final lonEncoded = (encoded[5] << 16) | (encoded[6] << 8) | encoded[7];
final altEncoded = (encoded[8] << 16) | (encoded[9] << 8) | encoded[10];
expect(latEncoded, equals(460569)); // 46.0569 * 10000
expect(lonEncoded, equals(145058)); // 14.5058 * 10000
expect(altEncoded, equals(29500)); // 295.0 * 100
});
test('GPS decoding uses correct 3-byte BE format and divisor', () {
// Create raw GPS telemetry packet (standard Cayenne LPP format)
final buffer = <int>[];
buffer.add(0); // channel
buffer.add(MeshCoreConstants.lppGps); // type 0x88
// Lat: 46.0569 * 10000 = 460569 = 0x070719
buffer.add(0x07); // MSB
buffer.add(0x07);
buffer.add(0x19); // LSB
// Lon: 14.5058 * 10000 = 145058 = 0x0236A2
buffer.add(0x02);
buffer.add(0x36);
buffer.add(0xA2);
// Alt: 295.0 * 100 = 29500 = 0x7 33C
buffer.add(0x00);
buffer.add(0x73);
buffer.add(0x3C);
final telemetry = CayenneLppParser.parse(Uint8List.fromList(buffer));
expect(telemetry.gpsLocation, isNotNull);
expect(telemetry.gpsLocation!.latitude, closeTo(46.0569, 0.0001));
expect(telemetry.gpsLocation!.longitude, closeTo(14.5058, 0.0001));
// Verify altitude is stored in extra data
expect(telemetry.extraSensorData, isNotNull);
expect(telemetry.extraSensorData!['altitude_0'], closeTo(295.0, 0.01));
});
test('GPS round-trip encoding/decoding maintains precision', () {
// Test various coordinates
final testCases = [
LatLng(46.0569, 14.5058), // Ljubljana
LatLng(37.7749, -122.4194), // San Francisco
LatLng(-33.8688, 151.2093), // Sydney
LatLng(0.0, 0.0), // Null Island
LatLng(89.9999, 179.9999), // Near max
LatLng(-89.9999, -179.9999), // Near min
];
for (final coords in testCases) {
final encoded = CayenneLppParser.createGpsData(
latitude: coords.latitude,
longitude: coords.longitude,
altitude: 100.0,
);
final decoded = CayenneLppParser.parse(encoded);
expect(
decoded.gpsLocation,
isNotNull,
reason: 'Failed to decode: $coords',
);
expect(
decoded.gpsLocation!.latitude,
closeTo(coords.latitude, 0.0001),
reason: 'Latitude mismatch for $coords',
);
expect(
decoded.gpsLocation!.longitude,
closeTo(coords.longitude, 0.0001),
reason: 'Longitude mismatch for $coords',
);
}
});
test('GPS decoding validates coordinate ranges', () {
// This test documents that coordinates are decoded correctly
// and any validation warnings are logged (not enforced)
// Valid coordinates should decode without issue
final validBuffer = <int>[];
validBuffer.add(0); // channel
validBuffer.add(MeshCoreConstants.lppGps);
// Lat: 45.0 * 10000 = 450000 = 0x06DDD0 (3 bytes BE)
validBuffer.addAll([0x06, 0xDD, 0xD0]);
// Lon: 10.0 * 10000 = 100000 = 0x0186A0 (3 bytes BE)
validBuffer.addAll([0x01, 0x86, 0xA0]);
// Alt: 0 (3 bytes BE)
validBuffer.addAll([0x00, 0x00, 0x00]);
final telemetry = CayenneLppParser.parse(Uint8List.fromList(validBuffer));
expect(telemetry.gpsLocation, isNotNull);
expect(telemetry.gpsLocation!.latitude, closeTo(45.0, 0.0001));
expect(telemetry.gpsLocation!.longitude, closeTo(10.0, 0.0001));
});
test('GPS encoding handles negative coordinates correctly', () {
const lat = -33.8688;
const lon = -151.2093;
final encoded = CayenneLppParser.createGpsData(
latitude: lat,
longitude: lon,
);
// Verify 3-byte signed encoding
// Lat: -33.8688 * 10000 = -338688
// In 24-bit two's complement: -338688 + 0x1000000 = 16438528 = 0xFAD500
final latEncoded = (encoded[2] << 16) | (encoded[3] << 8) | encoded[4];
// Lon: -151.2093 * 10000 = -1512093
// In 24-bit two's complement: -1512093 + 0x1000000 = 15265123 = 0xE8ED63
final lonEncoded = (encoded[5] << 16) | (encoded[6] << 8) | encoded[7];
expect(latEncoded, equals(0xFAD500)); // Verify two's complement
expect(lonEncoded, equals(0xE8ED63));
// Verify decoding
final decoded = CayenneLppParser.parse(encoded);
expect(decoded.gpsLocation!.latitude, closeTo(lat, 0.0001));
expect(decoded.gpsLocation!.longitude, closeTo(lon, 0.0001));
});
test('GPS encoding with altitude uses correct precision', () {
final encoded = CayenneLppParser.createGpsData(
latitude: 0.0,
longitude: 0.0,
altitude: 1234.56,
);
// Altitude is at bytes 8-10 (3 bytes BE)
// Alt: 1234.56 * 100 = 123456 = 0x01E240
final altEncoded = (encoded[8] << 16) | (encoded[9] << 8) | encoded[10];
// Altitude precision is 0.01m (divide by 100)
expect(altEncoded, equals(123456)); // 1234.56 * 100
final decoded = CayenneLppParser.parse(encoded);
expect(decoded.extraSensorData!['altitude_0'], closeTo(1234.56, 0.01));
});
test('GPS encoding supports custom channel', () {
final encoded = CayenneLppParser.createGpsData(
latitude: 1.0,
longitude: 2.0,
channel: 5,
);
expect(encoded[0], equals(5)); // channel
final decoded = CayenneLppParser.parse(encoded);
expect(decoded.gpsLocation, isNotNull);
expect(decoded.extraSensorData!['altitude_5'], isNotNull);
});
test(
'OLD BUG: Using 4-byte LE instead of 3-byte BE caused wrong coords',
() {
// This test documents the bug that was fixed
// The old code used 4-byte int32 LE (MeshCore advertisement format)
// instead of 3-byte signed BE (standard Cayenne LPP format)
// Real data from device:
// Hex: 06 f7 08 02 38 0e 01 2c 46
final realData = <int>[
0x00, 0x88, // channel 0, type GPS
0x06, 0xf7, 0x08, // lat (3 bytes BE)
0x02, 0x38, 0x0e, // lon (3 bytes BE)
0x01, 0x2c, 0x46, // alt (3 bytes BE)
];
// CORRECT decoding (3-byte BE):
final correctLat =
((0x06 << 16) | (0xf7 << 8) | 0x08) / 10000.0; // 45.6456°
final correctLon =
((0x02 << 16) | (0x38 << 8) | 0x0e) / 10000.0; // 14.5422°
// OLD BUGGY decoding (4-byte LE - reads wrong bytes!):
// Would read: lat=06f70802, lon=38010e2c (completely wrong)
final buggyLatRaw = 0x02 | (0x08 << 8) | (0xf7 << 16) | (0x06 << 24);
final buggyLat = buggyLatRaw / 10000.0; // 3414.1958° (out of range!)
// Verify current implementation decodes correctly
final telemetry = CayenneLppParser.parse(Uint8List.fromList(realData));
expect(telemetry.gpsLocation, isNotNull);
expect(telemetry.gpsLocation!.latitude, closeTo(correctLat, 0.0001));
expect(telemetry.gpsLocation!.longitude, closeTo(correctLon, 0.0001));
// Verify it doesn't produce the buggy values
expect(telemetry.gpsLocation!.latitude, isNot(equals(buggyLat)));
expect(telemetry.gpsLocation!.latitude, lessThan(90.0)); // Valid range
expect(telemetry.gpsLocation!.latitude, greaterThan(-90.0));
},
);
});
group('CayenneLppParser - Other Sensor Tests', () {
test('temperature encoding and decoding', () {
const tempCelsius = 23.5;
final encoded = CayenneLppParser.createTemperatureData(
tempCelsius,
channel: 1,
);
expect(encoded.length, equals(4)); // channel + type + 2 bytes
expect(encoded[0], equals(1)); // channel
expect(encoded[1], equals(MeshCoreConstants.lppTemperatureSensor));
final decoded = CayenneLppParser.parse(encoded);
expect(decoded.temperature, closeTo(tempCelsius, 0.1));
});
test('temperature handles negative values', () {
const tempCelsius = -15.3;
final encoded = CayenneLppParser.createTemperatureData(tempCelsius);
final decoded = CayenneLppParser.parse(encoded);
expect(decoded.temperature, closeTo(tempCelsius, 0.1));
});
test('battery voltage encoding and decoding', () {
const voltage = 3.85;
final encoded = CayenneLppParser.createBatteryData(voltage);
expect(encoded.length, equals(4));
expect(encoded[1], equals(MeshCoreConstants.lppAnalogInput));
final decoded = CayenneLppParser.parse(encoded);
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
expect(decoded.batteryPercentage, greaterThan(0));
expect(decoded.batteryPercentage, lessThanOrEqualTo(100));
});
test('battery percentage calculation', () {
// Test battery curve: 3.0V = 0%, 4.2V = 100%
final testCases = {
2.8: 0.0, // Below minimum
3.0: 0.0, // Minimum
3.6: 50.0, // Middle
4.2: 100.0, // Maximum
4.5: 100.0, // Above maximum
};
for (final entry in testCases.entries) {
final voltage = entry.key;
final expectedPercent = entry.value;
final encoded = CayenneLppParser.createBatteryData(voltage);
final decoded = CayenneLppParser.parse(encoded);
expect(
decoded.batteryPercentage,
closeTo(expectedPercent, 1),
reason: 'Battery ${voltage}V should be ~$expectedPercent%',
);
}
});
test('analog input is recognized as battery', () {
final buffer = ByteData(4);
buffer.setUint8(0, 0); // channel 0
buffer.setUint8(1, MeshCoreConstants.lppAnalogInput);
buffer.setInt16(2, 385, Endian.big); // 3.85V * 100
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
expect(decoded.batteryPercentage, isNotNull);
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
});
test('voltage sensor is recognized as battery', () {
final buffer = ByteData(4);
buffer.setUint8(0, 0);
buffer.setUint8(1, MeshCoreConstants.lppVoltageSensor);
buffer.setUint16(2, 385, Endian.big); // 3.85V * 100
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
expect(decoded.batteryPercentage, isNotNull);
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
});
test('humidity sensor decoding', () {
final buffer = ByteData(3);
buffer.setUint8(0, 0);
buffer.setUint8(1, MeshCoreConstants.lppHumiditySensor);
buffer.setUint8(2, 130); // 65% humidity (130 / 2)
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
expect(decoded.humidity, equals(65.0));
});
test('barometer sensor decoding', () {
final buffer = ByteData(4);
buffer.setUint8(0, 0);
buffer.setUint8(1, MeshCoreConstants.lppBarometer);
buffer.setUint16(2, 10132, Endian.big); // 1013.2 hPa * 10
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
expect(decoded.pressure, closeTo(1013.2, 0.1));
});
test('accelerometer sensor stores in extra data', () {
final buffer = ByteData(8);
buffer.setUint8(0, 0);
buffer.setUint8(1, MeshCoreConstants.lppAccelerometer);
buffer.setInt16(2, 1000, Endian.big); // x: 1.0 g
buffer.setInt16(4, -500, Endian.big); // y: -0.5 g
buffer.setInt16(6, 2000, Endian.big); // z: 2.0 g
final decoded = CayenneLppParser.parse(buffer.buffer.asUint8List());
expect(decoded.extraSensorData, isNotNull);
final accel = decoded.extraSensorData!['accelerometer_0'];
expect(accel['x'], closeTo(1.0, 0.001));
expect(accel['y'], closeTo(-0.5, 0.001));
expect(accel['z'], closeTo(2.0, 0.001));
});
test('extended MeshCore LPP types decode to structured extra data', () {
const lppGenericSensor = 100;
const lppCurrent = 117;
const lppFrequency = 118;
const lppAltitude = 121;
const lppConcentration = 125;
const lppPower = 128;
const lppSpeed = 129;
const lppDistance = 130;
const lppEnergy = 131;
const lppDirection = 132;
const lppUnixTime = 133;
const lppColour = 135;
const lppGust = 137;
const lppDewPoint = 138;
const lppRain = 139;
const lppSwitch = 142;
final payload = Uint8List.fromList([
2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300
3, lppCurrent, 0x00, 0x0F, // 0.015 A
4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz
5, lppAltitude, 0x01, 0xF4, // 500 m
6, lppConcentration, 0x01, 0x9F, // 415 ppm
7, lppPower, 0x00, 0xFA, // 250 W
8, lppSpeed, 0x04, 0xD2, // 12.34 m/s
9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m
10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh
11, lppDirection, 0x01, 0x0E, // 270 deg
12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312
13, lppColour, 0xFF, 0x80, 0x40, // #FF8040
15, lppGust, 0x01, 0x72, // 3.70 m/s
16, lppDewPoint, 0x00, 0x14, // 2.0 C
17, lppRain, 0x00, 0x7B, // 12.3 mm
14, lppSwitch, 0x01, // on
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.extraSensorData, isNotNull);
expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0));
expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001));
expect(decoded.extraSensorData!['frequency_4'], equals(1000.0));
expect(decoded.extraSensorData!['altitude_5'], equals(500.0));
expect(decoded.extraSensorData!['concentration_6'], equals(415.0));
expect(decoded.extraSensorData!['power_7'], equals(250.0));
expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001));
expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001));
expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001));
expect(decoded.extraSensorData!['direction_11'], equals(270.0));
expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456));
expect(
decoded.extraSensorData!['colour_13'],
equals({'r': 255, 'g': 128, 'b': 64}),
);
expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001));
expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001));
expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001));
expect(decoded.extraSensorData!['switch_14'], equals(1));
});
test('new MeshCore custom telemetry types decode without truncation', () {
const lppBinaryMoisture = 157;
const lppButtonEvent = 171;
const lppDimmer = 172;
const lppUv = 173;
const lppPm25 = 175;
const lppConductivity = 180;
const lppDuration = 182;
const lppSignedSpeed = 191;
const lppSignedPower = 192;
const lppSignedCurrent = 193;
final payload = Uint8List.fromList([
2, lppBinaryMoisture, 0x01,
3, lppButtonEvent, 0x04,
4, lppDimmer, 0xFB, // -5
5, lppUv, 0x0A, // 1.0
6, lppPm25, 0x00, 0x0C, // 12
7, lppConductivity, 0x01, 0xF4, // 500
8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s
9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s
10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W
11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A
12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.extraSensorData!['binary_moisture_2'], equals(1));
expect(decoded.extraSensorData!['button_event_3'], equals(4));
expect(decoded.extraSensorData!['dimmer_4'], equals(-5));
expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001));
expect(decoded.extraSensorData!['pm25_6'], equals(12.0));
expect(decoded.extraSensorData!['conductivity_7'], equals(500.0));
expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001));
expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001));
expect(
decoded.extraSensorData!['signed_power_10'],
closeTo(-10.0, 0.001),
);
expect(
decoded.extraSensorData!['signed_current_11'],
closeTo(-1.0, 0.001),
);
expect(decoded.temperature, closeTo(11.8, 0.1));
});
test(
'percentage battery and non-battery voltage channels are preserved separately',
() {
const lppPercentage = 120;
final payload = Uint8List.fromList([
1, lppPercentage, 66, // battery %
2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V
2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryPercentage, equals(66.0));
expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001));
expect(decoded.temperature, closeTo(23.5, 0.1));
expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1));
},
);
test('weather station payload keeps generic percentage and does not invent battery from ch1 voltage', () {
final payload = Uint8List.fromList([
0x01, 0x74, 0x00, 0x00,
0x02, 0x78, 0x64,
0x02, 0x73, 0x24, 0x32,
0x02, 0x65, 0x5A, 0x50,
0x02, 0x8A, 0xFF, 0xE4,
0x02, 0x74, 0x01, 0xE0,
0x02, 0x9D, 0x00,
0x02, 0x68, 0x76,
0x02, 0x81, 0x01, 0x4A,
0x02, 0x89, 0x01, 0xB8,
0x02, 0x67, 0x00, 0x2D,
0x02, 0xAD, 0x0A,
0x02, 0x84, 0x00, 0x56,
0x02, 0x8B, 0x00, 0xB3,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryPercentage, isNull);
expect(decoded.batteryMilliVolts, isNull);
expect(decoded.temperature, closeTo(4.5, 0.1));
expect(decoded.humidity, closeTo(59.0, 0.1));
expect(decoded.pressure, closeTo(926.6, 0.1));
expect(decoded.extraSensorData!['voltage_1'], closeTo(0.0, 0.001));
expect(decoded.extraSensorData!['percentage_2'], equals(100.0));
expect(decoded.extraSensorData!['uv_2'], equals(1.0));
expect(decoded.extraSensorData!['rain_2'], closeTo(17.9, 0.1));
expect(decoded.extraSensorData!.containsKey('__source_channel:battery'), isFalse);
});
test('unknown sensor type is skipped gracefully', () {
final buffer = ByteData(5);
buffer.setUint8(0, 0);
buffer.setUint8(1, 255); // Unknown type
buffer.setUint8(2, 1);
buffer.setUint8(3, 2);
buffer.setUint8(4, 3);
// Should not throw, just skip unknown data
expect(
() => CayenneLppParser.parse(buffer.buffer.asUint8List()),
returnsNormally,
);
});
test('multiple sensors in single packet', () {
// Create a combined packet with multiple sensors
final buffer = <int>[];
// GPS (channel 2) - use channel 2 to avoid battery auto-detection
buffer.add(2); // channel
buffer.add(MeshCoreConstants.lppGps);
// Lat: 46.0569 * 10000 = 460569 = 0x070719 (3 bytes BE)
buffer.addAll([0x07, 0x07, 0x19]);
// Lon: 14.5058 * 10000 = 145058 = 0x0236A2 (3 bytes BE)
buffer.addAll([0x02, 0x36, 0xA2]);
// Alt: 0 (3 bytes BE)
buffer.addAll([0x00, 0x00, 0x00]);
// Temperature (channel 3)
buffer.add(3); // channel
buffer.add(MeshCoreConstants.lppTemperatureSensor);
buffer.add(0x00); // 23.5°C * 10 = 235 (2 bytes BE)
buffer.add(0xEB);
// Battery (channel 0 - required for battery detection)
buffer.add(0); // channel
buffer.add(MeshCoreConstants.lppAnalogInput);
buffer.add(0x01); // 3.85V * 100 = 385 (2 bytes BE)
buffer.add(0x81);
final decoded = CayenneLppParser.parse(Uint8List.fromList(buffer));
// All sensors should be decoded
expect(decoded.gpsLocation, isNotNull);
expect(decoded.gpsLocation!.latitude, closeTo(46.0569, 0.0001));
expect(decoded.temperature, closeTo(23.5, 0.1));
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
});
test('stops parsing at zero-padded telemetry tail', () {
final payload = Uint8List.fromList([
0x01, 0x74, 0x01, 0x5F, // voltage 3.51V
0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
]);
final decoded = CayenneLppParser.parse(payload);
expect(decoded.batteryMilliVolts, closeTo(3510, 1));
expect(decoded.batteryPercentage, closeTo(42.5, 0.1));
expect(decoded.gpsLocation, isNotNull);
expect(decoded.gpsLocation!.latitude, 0.0);
expect(decoded.gpsLocation!.longitude, 0.0);
expect(decoded.extraSensorData?['digital_input_0'], isNull);
});
test('empty data returns empty telemetry', () {
final empty = Uint8List(0);
final decoded = CayenneLppParser.parse(empty);
expect(decoded.gpsLocation, isNull);
expect(decoded.batteryPercentage, isNull);
expect(decoded.temperature, isNull);
expect(decoded.extraSensorData, isNull);
expect(decoded.timestamp, isNotNull); // Timestamp is always set
});
test('timestamp is set to parse time', () {
final before = DateTime.now();
final data = CayenneLppParser.createTemperatureData(20.0);
final decoded = CayenneLppParser.parse(data);
final after = DateTime.now();
expect(
decoded.timestamp.isAfter(before) ||
decoded.timestamp.isAtSameMomentAs(before),
isTrue,
);
expect(
decoded.timestamp.isBefore(after) ||
decoded.timestamp.isAtSameMomentAs(after),
isTrue,
);
});
});
group('CayenneLppParser - ContactTelemetry Properties', () {
test('isRecent returns true for fresh telemetry', () {
final data = CayenneLppParser.createTemperatureData(20.0);
final telemetry = CayenneLppParser.parse(data);
expect(telemetry.isRecent, isTrue);
});
test('battery status helpers work correctly', () {
final lowBattery = ContactTelemetry(
batteryPercentage: 15.0,
timestamp: DateTime.now(),
);
expect(lowBattery.isLowBattery, isTrue);
expect(lowBattery.batteryStatus, equals('low'));
final criticalBattery = ContactTelemetry(
batteryPercentage: 5.0,
timestamp: DateTime.now(),
);
expect(criticalBattery.isCriticalBattery, isTrue);
final goodBattery = ContactTelemetry(
batteryPercentage: 75.0,
timestamp: DateTime.now(),
);
expect(goodBattery.isLowBattery, isFalse);
expect(goodBattery.batteryStatus, equals('good'));
});
test('copyWith creates modified copy', () {
final original = ContactTelemetry(
gpsLocation: LatLng(1, 2),
batteryPercentage: 50.0,
temperature: 20.0,
timestamp: DateTime.now(),
);
final modified = original.copyWith(temperature: 25.0);
expect(modified.temperature, equals(25.0));
expect(modified.batteryPercentage, equals(50.0)); // Unchanged
expect(modified.gpsLocation, equals(original.gpsLocation)); // Unchanged
});
test('toString provides readable output', () {
final telemetry = ContactTelemetry(
gpsLocation: LatLng(46.0569, 14.5058),
batteryPercentage: 75.0,
temperature: 23.5,
timestamp: DateTime.now(),
);
final str = telemetry.toString();
expect(str, contains('ContactTelemetry'));
expect(str, contains('46.0569'));
expect(str, contains('75'));
expect(str, contains('23.5'));
});
});
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/compass_support.dart';
void main() {
tearDown(() {
debugDefaultTargetPlatformOverride = null;
});
test('reports compass support only on mobile targets', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(CompassSupport.isAvailable, isTrue);
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
expect(CompassSupport.isAvailable, isTrue);
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
expect(CompassSupport.isAvailable, isFalse);
debugDefaultTargetPlatformOverride = TargetPlatform.windows;
expect(CompassSupport.isAvailable, isFalse);
});
}

View File

@@ -0,0 +1,143 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
void main() {
test('prefers known repeater route when available', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final routedRepeater = _contact(
name: 'Routed',
type: ContactType.repeater,
seed: 10,
lat: 46.15,
lon: 14.60,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [routedRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.tokens, [
'AA',
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
]);
});
test('falls back to location-only repeaters', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final nearRepeater = _contact(
name: 'Near',
type: ContactType.repeater,
seed: 10,
lat: 46.08,
lon: 14.55,
);
final farRepeater = _contact(
name: 'Far',
type: ContactType.repeater,
seed: 11,
lat: 46.40,
lon: 15.10,
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [nearRepeater, farRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.first.displayName, 'Near');
});
test('for known routes prefers anchor closest to the chain end', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final chainHop = _contact(
name: 'Chain Hop',
type: ContactType.repeater,
seed: 0xAA,
lat: 46.01,
lon: 14.51,
);
final nearAnchor = _contact(
name: 'Near Anchor',
type: ContactType.repeater,
seed: 10,
lat: 46.03,
lon: 14.53,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final farAnchor = _contact(
name: 'Far Anchor',
type: ContactType.repeater,
seed: 11,
lat: 46.18,
lon: 14.68,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [chainHop, nearAnchor, farAnchor],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
});
}
Contact _contact({
required String name,
required ContactType type,
required int seed,
required double lat,
required double lon,
int signedPathLen = -1,
Uint8List? outPath,
}) {
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath ?? Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (lat * 1e6).round(),
advLon: (lon * 1e6).round(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}

View File

@@ -0,0 +1,56 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/contact_storage_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({});
});
test('keeps default and custom profile contact storage isolated', () async {
final storage = ContactStorageService();
final defaultContact = createContact(
key: Uint8List.fromList(List<int>.filled(32, 1)),
name: 'Default Contact',
);
final customContact = createContact(
key: Uint8List.fromList(List<int>.filled(32, 2)),
name: 'Custom Contact',
);
await storage.saveContacts([defaultContact]);
await storage.saveContacts([customContact], namespace: 'alpha');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('stored_contacts'), isNotNull);
expect(prefs.getString('profile.alpha.stored_contacts'), isNotNull);
final defaultContacts = await storage.loadContacts();
final customContacts = await storage.loadContacts(namespace: 'alpha');
expect(defaultContacts.single.advName, defaultContact.advName);
expect(customContacts.single.advName, customContact.advName);
expect(defaultContacts.single.publicKey, defaultContact.publicKey);
expect(customContacts.single.publicKey, customContact.publicKey);
});
}

View File

@@ -0,0 +1,213 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/services/live_traffic_summary.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
group('LiveTrafficSummary', () {
test('uses only the rolling 60-second window', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 61)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.tx,
rawData: [0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.rx,
rawData: [0x01, 0x02],
responseCode: 0x01,
),
], now: now);
expect(snapshot.totalCount, 1);
expect(snapshot.rxCount, 1);
expect(snapshot.txCount, 0);
expect(snapshot.packetsPerMinute, 1);
});
test('aggregates RSSI, SNR, and multi-hop route metrics', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 12.0,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 15)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]),
responseCode: 0x88,
snrDb: 6.0,
rssiDbm: -90,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
], now: now);
expect(snapshot.latestRssiDbm, -90);
expect(snapshot.latestSnrDb, 6.0);
expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01));
expect(snapshot.avgSnrDb, closeTo(9.0, 0.01));
expect(snapshot.multiHopCount, 2);
expect(snapshot.avgHopCount, closeTo(2.5, 0.01));
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
});
test('normalizes packet rate for windows longer than one minute', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs(
List.generate(
24,
(index) => _log(
timestamp: now.subtract(Duration(seconds: index * 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
),
now: now,
window: const Duration(minutes: 5),
);
expect(snapshot.totalCount, 24);
expect(snapshot.windowDuration, const Duration(minutes: 5));
expect(snapshot.packetsPerMinute, 5);
});
test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8));
final snapshot = LiveTrafficSummary.fromLogs(
[
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
],
now: now,
clearedAt: clearAt,
);
expect(snapshot.totalCount, 1);
expect(snapshot.visibleEntries, hasLength(1));
});
test('counts only RX data packets for device totals', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
expect(
LiveTrafficSummary.countRxDataLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.tx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]),
1,
);
});
test('exposes detailed descriptions for known packet types', () {
final returnedPath = LiveTrafficEntry.payloadTypeDetails(0x08);
final control = LiveTrafficEntry.payloadTypeDetails(0x0B);
expect(returnedPath.title, 'FLOOD RETURNED_PATH');
expect(
returnedPath.description,
contains('stores that returned path as the peer\'s direct out-path'),
);
expect(control.label, 'Control packet');
expect(control.description, contains('discovery request and response'));
});
});
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/location_tracking_service.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
void main() {
final service = LocationTrackingService();
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: false,
activeProfileId: 'default',
);
service.fastLocationUpdatesEnabled = false;
service.fastLocationMovementThresholdMeters = 10.0;
service.fastLocationActiveCadenceSeconds = 60;
service.fastLocationChannelIdx = null;
});
test('loads conservative fast location defaults', () async {
await service.loadSettings();
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 60);
});
test('persists and restores fast location channel idx', () async {
await service.updateFastLocationChannelIdx(3);
service.fastLocationChannelIdx = null;
await service.loadSettings();
expect(service.fastLocationChannelIdx, 3);
});
test('clears persisted fast location channel idx when unset', () async {
await service.updateFastLocationChannelIdx(7);
await service.updateFastLocationChannelIdx(null);
service.fastLocationChannelIdx = 99;
await service.loadSettings();
expect(service.fastLocationChannelIdx, isNull);
});
test('clamps fast location settings to conservative limits', () async {
await service.updateFastLocationMovementThreshold(3);
await service.updateFastLocationActiveCadenceSeconds(45);
expect(service.fastLocationMovementThresholdMeters, 10.0);
expect(service.fastLocationActiveCadenceSeconds, 60);
});
}

View File

@@ -0,0 +1,63 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/map_marker_service.dart';
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
void main() {
Contact buildContact({
required String name,
required ContactType type,
required int advLat,
required int advLon,
}) {
return Contact(
publicKey: Uint8List(32),
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch,
advLat: advLat,
advLon: advLon,
lastMod: DateTime.now().millisecondsSinceEpoch,
);
}
testWidgets('contact map markers render shared contact avatars', (tester) async {
final service = MapMarkerService();
final contact = buildContact(
name: 'John Smith',
type: ContactType.chat,
advLat: (46.0569 * 1e6).round(),
advLon: (14.5058 * 1e6).round(),
);
late Widget markerChild;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final markers = service.generateContactMarkers(
contacts: [contact],
context: context,
);
markerChild = markers.single.child;
return const SizedBox.shrink();
},
),
),
);
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: Center(child: markerChild))),
);
expect(find.byType(ContactAvatar), findsOneWidget);
expect(find.text('JS'), findsOneWidget);
});
}

View File

@@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await MeshMapNodesService.clearCache();
});
test('fetchNodes persists nodes and cached lookup works offline', () async {
final client = MockClient((request) async {
expect(
request.url.toString(),
'https://api.meshcore.nz/api/v1/map/nodes',
);
return http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
);
});
final nodes = await MeshMapNodesService.fetchNodes(client: client);
final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false);
expect(nodes, hasLength(1));
expect(cached, hasLength(1));
expect(cached.first.name, 'Alpha');
expect(await MeshMapNodesService.hasFreshCache(), isTrue);
});
test('loadCachedNodes ignores stale persisted cache', () async {
SharedPreferences.setMockInitialValues({
'mesh_map_nodes_cache_v1': jsonEncode([
{
'type': 1,
'name': 'Old node',
'public_key': 'bb22cc33',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
]),
'mesh_map_nodes_cache_timestamp_v1': DateTime.now()
.subtract(const Duration(hours: 25))
.millisecondsSinceEpoch,
});
final nodes = await MeshMapNodesService.loadCachedNodes();
expect(nodes, isEmpty);
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
});
test('clearCache removes persisted online node cache', () async {
final client = MockClient(
(_) async => http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
),
);
await MeshMapNodesService.fetchNodes(client: client);
await MeshMapNodesService.clearCache();
expect(await MeshMapNodesService.loadCachedNodes(), isEmpty);
expect(await MeshMapNodesService.cachedAt(), isNull);
});
test('fetchNodes ignores nodes with invalid coordinates', () async {
final client = MockClient(
(_) async => http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Broken',
'public_key': 'bad123',
'latitude': 3213.0,
'longitude': 14.50,
'updated_at': 123456,
},
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
),
);
final nodes = await MeshMapNodesService.fetchNodes(client: client);
expect(nodes, hasLength(1));
expect(nodes.first.name, 'Alpha');
});
}

View File

@@ -0,0 +1,192 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
import 'package:meshcore_sar_app/services/message_storage_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final originalDebugPrint = debugPrint;
setUp(() {
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
debugPrint = originalDebugPrint;
});
test(
'retains path bytes for stored unread message when reception sidecar is missing',
() async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-1',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Unread message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
pathBytes: const [0xAA, 0xBB, 0xCC],
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restoredMessages = await storage.loadMessages();
expect(restoredDetails.keys, contains(message.id));
expect(restoredDetails[message.id]?.pathBytes, [0xAA, 0xBB, 0xCC]);
expect(restoredMessages.single.pathBytes, [0xAA, 0xBB, 0xCC]);
},
);
test('retains embedded reception details when sidecar is missing', () async {
final storage = MessageStorageService();
final message = Message(
id: 'msg-2',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([6, 5, 4, 3, 2, 1]),
channelIdx: 2,
pathLen: 3,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Room update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000100500),
isRead: false,
);
await storage.saveMessages(
[message],
messageReceptionDetails: {
message.id: MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000100600),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000100400),
rssiDbm: -91,
snrDb: 7.25,
pathBytes: const [0xAA, 0xBB, 0xCC, 0xDD],
senderToReceiptMs: 1200,
estimatedTransmitMs: 800,
postTransmitDelayMs: 400,
receivedCopies: 3,
),
},
);
final prefs = await SharedPreferences.getInstance();
await prefs.remove('stored_message_reception_details');
final restoredDetails = await storage.loadMessageReceptionDetails();
final restored = restoredDetails[message.id];
expect(restored, isNotNull);
expect(restored!.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
expect(restored.rssiDbm, -91);
expect(restored.snrDb, 7.25);
expect(restored.senderToReceiptMs, 1200);
expect(restored.estimatedTransmitMs, 800);
expect(restored.postTransmitDelayMs, 400);
expect(restored.receivedCopies, 3);
expect(
restored.packetLoggedAt,
DateTime.fromMillisecondsSinceEpoch(1700000100400),
);
});
test('persists removed SAR marker IDs', () async {
final storage = MessageStorageService();
await storage.saveRemovedSarMarkerIds({'sar-2', 'sar-1'});
final restored = await storage.loadRemovedSarMarkerIds();
expect(restored, equals({'sar-1', 'sar-2'}));
});
test('keeps default and custom profile message storage isolated', () async {
final storage = MessageStorageService();
final defaultMessage = Message(
id: 'default-msg',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([1, 1, 1, 1, 1, 1]),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700001000,
text: 'Default profile',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700001000500),
isRead: true,
);
final customMessage = Message(
id: 'custom-msg',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([2, 2, 2, 2, 2, 2]),
channelIdx: 1,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700002000,
text: 'Custom profile',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700002000500),
isRead: false,
);
await storage.saveMessages([defaultMessage]);
await storage.saveMessages([customMessage], namespace: 'alpha');
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('stored_messages'), isNotNull);
expect(prefs.getString('profile.alpha.stored_messages'), isNotNull);
final defaultMessages = await storage.loadMessages();
final customMessages = await storage.loadMessages(namespace: 'alpha');
expect(defaultMessages.single.id, defaultMessage.id);
expect(defaultMessages.single.text, defaultMessage.text);
expect(customMessages.single.id, customMessage.id);
expect(customMessages.single.text, customMessage.text);
});
test('skips unchanged message snapshots', () async {
final storage = MessageStorageService();
final logs = <String>[];
debugPrint = (String? message, {int? wrapWidth}) {
if (message != null) {
logs.add(message);
}
};
final message = Message(
id: 'msg-stable',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([3, 3, 3, 3, 3, 3]),
channelIdx: 3,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700003000,
text: 'Stable snapshot',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700003000500),
isRead: true,
);
await storage.saveMessages([message]);
await storage.saveMessages([message]);
expect(
logs.where((log) => log.contains('Saved 1 messages to storage')),
hasLength(1),
);
});
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/services/messaging_route_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('route preference defaults are clear-path disabled and fallback enabled', () async {
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isFalse);
expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isTrue,
);
});
test('route preferences persist changes', () async {
await MessagingRoutePreferences.setClearPathOnMaxRetry(true);
await MessagingRoutePreferences.setNearestRelayFallbackEnabled(false);
expect(await MessagingRoutePreferences.getClearPathOnMaxRetry(), isTrue);
expect(
await MessagingRoutePreferences.getNearestRelayFallbackEnabled(),
isFalse,
);
});
test('legacy auto route rotation preference is removed during cleanup', () async {
SharedPreferences.setMockInitialValues({
'messaging_auto_route_rotation_enabled': true,
});
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('messaging_auto_route_rotation_enabled'), isTrue);
await MessagingRoutePreferences.cleanupLegacySettings();
expect(
prefs.containsKey('messaging_auto_route_rotation_enabled'),
isFalse,
);
});
}

View File

@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/nearest_router_selector.dart';
Contact _buildRepeater({
required int seed,
required String name,
required double latitude,
required double longitude,
required int lastAdvert,
int outPathLen = -1,
}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.repeater,
flags: 0,
outPathLen: outPathLen,
outPath: Uint8List(0),
advName: name,
lastAdvert: lastAdvert,
advLat: (latitude * 1e6).round(),
advLon: (longitude * 1e6).round(),
lastMod: lastAdvert,
);
}
Position _position(double latitude, double longitude) {
return Position(
latitude: latitude,
longitude: longitude,
timestamp: DateTime.now(),
accuracy: 1,
altitude: 0,
altitudeAccuracy: 1,
heading: 0,
headingAccuracy: 1,
speed: 0,
speedAccuracy: 0,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('selector chooses nearest eligible repeater', () {
final selector = NearestRouterSelector();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final recipient = _buildRepeater(
seed: 90,
name: 'Recipient',
latitude: 46.05,
longitude: 14.50,
lastAdvert: now,
).copyWith(type: ContactType.chat);
final selected = selector.select(
senderPosition: _position(46.0569, 14.5058),
repeaters: [
_buildRepeater(
seed: 1,
name: 'Far',
latitude: 46.10,
longitude: 14.60,
lastAdvert: now,
outPathLen: 1,
),
_buildRepeater(
seed: 2,
name: 'Near',
latitude: 46.0570,
longitude: 14.5060,
lastAdvert: now - 5,
outPathLen: 1,
),
],
recipient: recipient,
);
expect(selected?.advName, 'Near');
});
test('selector skips stale repeaters', () {
final selector = NearestRouterSelector();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final staleAdvert = now - (11 * 60);
final recipient = _buildRepeater(
seed: 91,
name: 'Recipient',
latitude: 46.05,
longitude: 14.50,
lastAdvert: now,
).copyWith(type: ContactType.chat);
final selected = selector.select(
senderPosition: _position(46.0569, 14.5058),
repeaters: [
_buildRepeater(
seed: 3,
name: 'Stale',
latitude: 46.0570,
longitude: 14.5060,
lastAdvert: staleAdvert,
outPathLen: 1,
),
],
recipient: recipient,
);
expect(selected, isNull);
});
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/notification_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('discovery notification preference persists independently', () async {
final service = NotificationService();
await service.setMessageNotificationsEnabled(true);
await service.setDiscoveryNotificationsEnabled(false);
final prefs = await SharedPreferences.getInstance();
expect(service.messageNotificationsEnabled, isTrue);
expect(service.discoveryNotificationsEnabled, isFalse);
expect(prefs.getBool('notifications_messages_enabled'), isTrue);
expect(prefs.getBool('notifications_discovery_enabled'), isFalse);
});
}

View File

@@ -0,0 +1,110 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/offline_map_caching_provider.dart';
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
late Directory tempDir;
final cache = OfflineTileCacheService.instance;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('offline_map_provider_test_');
await cache.resetForTesting();
cache.setBaseDirForTesting('${tempDir.path}/tiles');
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
});
tearDown(() async {
await cache.resetForTesting();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('parses query-style tile URLs used by Google layers', () {
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
);
expect(coords, isNotNull);
expect(coords!.z, 13);
expect(coords.x, 4312);
expect(coords.y, 2810);
expect(
OfflineMapCachingProvider.extractUrlTemplateForTesting(
'http://mt0.google.com/vt/lyrs=m&hl=en&x=4312&y=2810&z=13',
),
'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}',
);
});
test('parses Esri tile URLs and preserves y/x order in template', () {
final coords = OfflineMapCachingProvider.parseTileUrlForTesting(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267',
);
expect(coords, isNotNull);
expect(coords!.z, 9);
expect(coords.x, 267);
expect(coords.y, 173);
expect(
OfflineMapCachingProvider.extractUrlTemplateForTesting(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267',
),
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
);
});
test('returns cached raw bytes directly', () async {
const url =
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/9/173/267';
final template = OfflineMapCachingProvider.extractUrlTemplateForTesting(url);
final styleHash = cache.styleHashFromUrl(template);
final bytes = Uint8List.fromList([1, 2, 3]);
await cache.saveStyleMeta(
styleHash,
displayName: 'Esri',
urlTemplate: template,
);
await cache.putTile(
styleHash,
9,
267,
173,
bytes,
contentType: 'image/jpeg',
sourceUrl: url,
);
final provider = OfflineMapCachingProvider(_NullMapCachingProvider());
final tile = await provider.getTile(url);
expect(tile, isNotNull);
expect(tile!.bytes, bytes);
});
}
class _NullMapCachingProvider implements MapCachingProvider {
@override
bool get isSupported => true;
@override
Future<CachedMapTile?> getTile(String url) async => null;
@override
Future<void> putTile({
required String url,
required CachedMapTileMetadata metadata,
Uint8List? bytes,
}) async {}
}

View File

@@ -0,0 +1,118 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/services/offline_tile_cache_service.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
void main() {
late Directory tempDir;
final cache = OfflineTileCacheService.instance;
setUpAll(() {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
});
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('offline_tile_cache_test_');
await cache.resetForTesting();
cache.setBaseDirForTesting('${tempDir.path}/tiles');
cache.setDatabasePathForTesting('${tempDir.path}/tiles.db');
});
tearDown(() async {
await cache.resetForTesting();
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
test('stores original bytes and content type without conversion', () async {
const styleHash = 'abcdef123456';
final bytes = Uint8List.fromList([1, 2, 3, 4]);
await cache.saveStyleMeta(
styleHash,
displayName: 'Test layer',
urlTemplate: 'https://example.com/{z}/{x}/{y}.png',
);
await cache.putTile(
styleHash,
8,
12,
34,
bytes,
contentType: 'image/png; charset=binary',
sourceUrl: 'https://example.com/8/12/34.png',
);
final tile = await cache.getTileData(styleHash, 8, 12, 34);
expect(tile, isNotNull);
expect(tile!.bytes, bytes);
expect(tile.contentType, 'image/png');
expect(await cache.hasTile(styleHash, 8, 12, 34), isTrue);
expect(await cache.getCacheSize(), bytes.length);
final styles = await cache.listStylesDetailed();
expect(styles, hasLength(1));
expect(styles.single.tileCount, 1);
expect(styles.single.sizeBytes, bytes.length);
});
test('hydrates existing disk cache into sqlite on first access', () async {
const styleHash = 'fedcba654321';
final base = Directory('${tempDir.path}/tiles/$styleHash/9/13');
await base.create(recursive: true);
final file = File('${base.path}/42.avif');
await file.writeAsBytes([9, 8, 7], flush: true);
await File('${tempDir.path}/tiles/$styleHash/meta.json').writeAsString(
jsonEncode({
'displayName': 'Legacy layer',
'urlTemplate': 'https://example.com/{z}/{x}/{y}.avif',
}),
flush: true,
);
final tiles = await cache.listTilesForStyle(styleHash);
expect(tiles, hasLength(1));
expect(tiles.single.z, 9);
expect(tiles.single.x, 13);
expect(tiles.single.y, 42);
final tile = await cache.getTileData(styleHash, 9, 13, 42);
expect(tile, isNotNull);
expect(tile!.bytes, Uint8List.fromList([9, 8, 7]));
expect(tile.contentType, 'image/avif');
final styles = await cache.listStylesDetailed();
expect(styles.single.displayName, 'Legacy layer');
expect(styles.single.tileCount, 1);
expect(styles.single.sizeBytes, 3);
});
test('deletes style metadata and files', () async {
const styleHash = 'aabbccddeeff';
await cache.saveStyleMeta(
styleHash,
displayName: 'Delete me',
urlTemplate: 'https://example.com/{z}/{x}/{y}.jpg',
);
await cache.putTile(
styleHash,
1,
2,
3,
Uint8List.fromList([5, 6]),
contentType: 'image/jpeg',
);
expect(await cache.getCacheSize(), 2);
await cache.deleteStyle(styleHash);
expect(await cache.getCacheSize(), 0);
expect(await cache.getTileData(styleHash, 1, 2, 3), isNull);
expect(await Directory('${tempDir.path}/tiles/$styleHash').exists(), isFalse);
});
}

View File

@@ -0,0 +1,183 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/services/path_history_service.dart';
Contact _buildContact({
required int seed,
List<int> pathBytes = const [],
int hopCount = 0,
int hashSize = 1,
}) {
final encoded = pathBytes.isEmpty ? -1 : ((hashSize - 1) << 6) | (hopCount & 0x3F);
final outPath = Uint8List(ContactRouteCodec.maxPathBytes);
if (pathBytes.isNotEmpty) {
outPath.setRange(0, pathBytes.length, pathBytes);
}
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i + seed)),
type: ContactType.chat,
flags: 0,
outPathLen: encoded == -1 ? -1 : ContactRouteCodec.toSignedDescriptor(encoded),
outPath: encoded == -1 ? Uint8List(0) : outPath,
advName: 'Contact $seed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('manual route override persists across reloads', () async {
final contact = _buildContact(seed: 1);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
final reloaded = PathHistoryService();
final selection = await reloaded.getManualSelectionForContact(contact);
expect(selection, isNotNull);
expect(selection!.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AA,BB');
});
test('selection uses stored manual route before contact route', () async {
final contact = _buildContact(
seed: 2,
pathBytes: const [0x11, 0x22],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, 'AA,BB');
});
test('selection falls back to the current contact route', () async {
final contact = _buildContact(
seed: 3,
pathBytes: const [0x10, 0x20, 0x30],
hopCount: 1,
hashSize: 3,
);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '102030');
expect(selection.hashSize, 3);
expect(selection.hopCount, 1);
});
test('selection falls back to flood when no route exists', () async {
final contact = _buildContact(seed: 4);
final service = PathHistoryService();
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.flood);
expect(selection.pathBytes, isEmpty);
});
test('clearing manual route falls back to the contact route', () async {
final contact = _buildContact(
seed: 5,
pathBytes: const [0x01, 0x02],
hopCount: 2,
hashSize: 1,
);
final service = PathHistoryService();
await service.initialize();
await service.setManualSelectionFor(
contact.publicKeyHex,
PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList([0xAA, 0xBB]),
hopCount: 2,
hashSize: 1,
),
);
await service.clearManualRouteFor(contact.publicKeyHex);
final selection = await service.getSelectionForContact(contact);
expect(selection.mode, PathSelectionMode.directCurrent);
expect(selection.canonicalPath, '01,02');
});
test('initialize removes legacy path history storage', () async {
final contact = Contact(
publicKey: Uint8List.fromList([
0xAB,
0xC1,
0x23,
...List<int>.filled(29, 0),
]),
type: ContactType.chat,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: 'Legacy Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
SharedPreferences.setMockInitialValues({
'contact_path_history_v2': '{"abc123":{"direct_paths":[]}}',
'contact_manual_path_overrides_v1': jsonEncode({
contact.publicKeyHex: {
'pathBytes': [0xAA, 0xBB],
'hopCount': 2,
'hashSize': 1,
},
}),
});
final service = PathHistoryService();
await service.initialize();
final prefs = await SharedPreferences.getInstance();
expect(prefs.containsKey('contact_path_history_v2'), isFalse);
final selection = await service.getManualSelectionForContact(contact);
expect(selection, isNotNull);
expect(selection!.canonicalPath, 'AA,BB');
});
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/config_profile.dart';
import 'package:meshcore_sar_app/services/profile_manager.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: ConfigProfile.defaultProfileId,
);
});
group('ProfileManager', () {
test('shows the built-in default profile on a fresh install', () async {
final manager = ProfileManager();
await manager.initialize();
expect(manager.activeProfileId, ConfigProfile.defaultProfileId);
expect(manager.visibleProfiles, hasLength(1));
expect(
manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault,
isTrue,
);
expect(ProfileStorageScope.profilesEnabled, isTrue);
expect(ProfileStorageScope.effectiveNamespace, isNull);
expect(manager.visibleProfiles.single.name, 'Default');
expect(ProfileStorageScope.effectiveNamespace, isNull);
});
test(
'persists custom profiles and restores scoped active profile state',
() async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final profile = ConfigProfile(
id: 'profile-alpha',
name: 'Alpha',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(profile);
await manager.setActiveProfileId(profile.id);
final reloaded = ProfileManager();
await reloaded.initialize();
expect(reloaded.profilesEnabled, isTrue);
expect(reloaded.activeProfileId, profile.id);
expect(reloaded.visibleProfiles.map((item) => item.id), [
ConfigProfile.defaultProfileId,
profile.id,
]);
expect(reloaded.getProfile(profile.id)?.name, profile.name);
expect(ProfileStorageScope.effectiveNamespace, profile.id);
await reloaded.setProfilesEnabled(false);
expect(ProfileStorageScope.profilesEnabled, isFalse);
expect(ProfileStorageScope.effectiveNamespace, isNull);
expect(reloaded.activeProfileId, profile.id);
},
);
test('stores per-device default profiles independently', () async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final profile = ConfigProfile(
id: 'profile-alpha',
name: 'Alpha',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(profile);
await manager.setActiveProfileIdForDevice(
profile.id,
deviceKey: 'pk:device-a',
);
await manager.setActiveProfileIdForDevice(
ConfigProfile.defaultProfileId,
deviceKey: 'pk:device-b',
);
final reloaded = ProfileManager();
await reloaded.initialize();
expect(reloaded.profileIdForDevice('pk:device-a'), profile.id);
expect(
reloaded.profileIdForDevice('pk:device-b'),
ConfigProfile.defaultProfileId,
);
expect(
reloaded.profileIdForDevice('pk:device-c'),
ConfigProfile.defaultProfileId,
);
expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue);
expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse);
});
});
}

View File

@@ -0,0 +1,451 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_sar_app/models/config_profile.dart';
import 'package:meshcore_sar_app/models/contact_group.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/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart';
import 'package:meshcore_sar_app/services/contact_storage_service.dart';
import 'package:meshcore_sar_app/services/device_config_applicator.dart';
import 'package:meshcore_sar_app/services/message_storage_service.dart';
import 'package:meshcore_sar_app/services/profile_manager.dart';
import 'package:meshcore_sar_app/services/profile_workspace_coordinator.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:meshcore_sar_app/services/map_workspace_snapshot_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: ConfigProfile.defaultProfileId,
);
});
group('ProfileWorkspaceCoordinator', () {
test(
'createProfileFromCurrent starts with empty contacts and messages',
() async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final messageStorage = MessageStorageService();
final contactStorage = ContactStorageService();
await messageStorage.saveMessages([_buildMessage('default-msg')]);
await contactStorage.saveContacts([
_buildContact('Default Contact', 1),
]);
await contactStorage.saveContactGroups([
SavedContactGroup(
id: 'group-1',
sectionKey: 'manual',
label: 'Team',
query: '',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
),
]);
await contactStorage.savePendingAdverts([
{'id': 'pending-1'},
]);
final coordinator = _buildCoordinator(profileManager: manager);
final profile = await coordinator.createProfileFromCurrent(
name: 'Empty Profile',
);
expect(
await messageStorage.loadMessages(namespace: profile.id),
isEmpty,
);
expect(
await contactStorage.loadContacts(namespace: profile.id),
isEmpty,
);
expect(
await contactStorage.loadContactGroups(namespace: profile.id),
isEmpty,
);
expect(
await contactStorage.loadPendingAdverts(namespace: profile.id),
isEmpty,
);
expect(await messageStorage.loadMessages(), hasLength(1));
expect(await contactStorage.loadContacts(), hasLength(1));
},
);
test(
'duplicateProfile copies contacts and messages from source profile',
() async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final source = ConfigProfile(
id: 'profile-source',
name: 'Source',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(source);
final messageStorage = MessageStorageService();
final contactStorage = ContactStorageService();
await messageStorage.saveMessages([
_buildMessage('source-msg'),
], namespace: source.id);
await contactStorage.saveContacts([
_buildContact('Source Contact', 2),
], namespace: source.id);
await contactStorage.saveContactGroups([
SavedContactGroup(
id: 'source-group',
sectionKey: 'manual',
label: 'Source Team',
query: '',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
),
], namespace: source.id);
await contactStorage.savePendingAdverts([
{'id': 'source-pending'},
], namespace: source.id);
final coordinator = _buildCoordinator(profileManager: manager);
final duplicate = await coordinator.duplicateProfile(source);
final duplicatedMessages = await messageStorage.loadMessages(
namespace: duplicate.id,
);
final duplicatedContacts = await contactStorage.loadContacts(
namespace: duplicate.id,
);
final duplicatedGroups = await contactStorage.loadContactGroups(
namespace: duplicate.id,
);
final duplicatedPending = await contactStorage.loadPendingAdverts(
namespace: duplicate.id,
);
expect(duplicatedMessages.map((item) => item.id), ['source-msg']);
expect(duplicatedContacts.map((item) => item.advName), [
'Source Contact',
]);
expect(duplicatedGroups.map((item) => item.id), ['source-group']);
expect(duplicatedPending, [
{'id': 'source-pending'},
]);
},
);
test(
'openProfile disconnects current connection before switching',
() async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final target = ConfigProfile(
id: 'profile-target',
name: 'Target',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(target);
final connectionProvider = _FakeConnectionProvider();
final coordinator = _buildCoordinator(
profileManager: manager,
connectionProvider: connectionProvider,
);
await coordinator.openProfile(target.id);
expect(connectionProvider.disconnectCallCount, 1);
expect(manager.activeProfileId, target.id);
},
);
test('syncActiveProfileForCurrentDevice uses per-device default', () async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final alpha = ConfigProfile(
id: 'profile-alpha',
name: 'Alpha',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(alpha);
await manager.setActiveProfileIdForDevice(
alpha.id,
deviceKey: 'pk:01020304',
);
await manager.setActiveProfileId(ConfigProfile.defaultProfileId);
final connectionProvider = _FakeConnectionProvider(
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
);
final voiceProvider = _FakeVoiceProvider();
final imageProvider = _FakeImageProvider();
final coordinator = _buildCoordinator(
profileManager: manager,
connectionProvider: connectionProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
await coordinator.syncActiveProfileForCurrentDevice();
expect(manager.activeProfileId, alpha.id);
expect(connectionProvider.disconnectCallCount, 0);
expect(voiceProvider.reloadCallCount, 1);
expect(imageProvider.reloadCallCount, 1);
});
test(
'syncActiveProfileForCurrentDevice creates a profile for a new device',
() async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final connectionProvider = _FakeConnectionProvider(
deviceInfo: DeviceInfo(
deviceId: 'ble-77',
deviceName: 'MeshCore-Field Unit',
selfName: 'Field Unit',
publicKey: Uint8List.fromList([7, 7, 7, 7]),
),
);
final coordinator = _buildCoordinator(
profileManager: manager,
connectionProvider: connectionProvider,
);
await coordinator.syncActiveProfileForCurrentDevice();
expect(manager.activeProfileId, isNot(ConfigProfile.defaultProfileId));
final profile = manager.getProfile(manager.activeProfileId);
expect(profile, isNotNull);
expect(profile!.name, 'Device Field Unit');
expect(manager.hasProfileForDevice('pk:07070707'), isTrue);
expect(
manager.profileIdForDevice('pk:07070707'),
manager.activeProfileId,
);
},
);
});
}
ProfileWorkspaceCoordinator _buildCoordinator({
required ProfileManager profileManager,
_FakeConnectionProvider? connectionProvider,
_FakeVoiceProvider? voiceProvider,
_FakeImageProvider? imageProvider,
}) {
return ProfileWorkspaceCoordinator(
profileManager: profileManager,
connectionProvider: connectionProvider ?? _FakeConnectionProvider(),
contactsProvider: _FakeContactsProvider(),
messagesProvider: _FakeMessagesProvider(),
sensorsProvider: _FakeSensorsProvider(),
mapProvider: _FakeMapProvider(),
drawingProvider: _FakeDrawingProvider(),
channelsProvider: _FakeChannelsProvider(),
voiceProvider: voiceProvider ?? _FakeVoiceProvider(),
imageProvider: imageProvider ?? _FakeImageProvider(),
appProvider: _FakeAppProvider(),
appConfigSnapshotService: _FakeAppConfigSnapshotService(),
mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(),
deviceConfigApplicator: _FakeDeviceConfigApplicator(),
);
}
Message _buildMessage(String id) {
return Message(
id: id,
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: id,
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
isRead: true,
);
}
Contact _buildContact(String name, int fillByte) {
return Contact(
publicKey: Uint8List.fromList(List<int>.filled(32, fillByte)),
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,
);
}
class _FakeAppConfigSnapshotService extends AppConfigSnapshotService {
@override
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
return const AppSettingsProfileSection();
}
}
class _FakeMapWorkspaceSnapshotService extends MapWorkspaceSnapshotService {
@override
Future<MapWorkspaceProfileSection> capture({
required MapProvider mapProvider,
required DrawingProvider drawingProvider,
}) async {
return const MapWorkspaceProfileSection();
}
}
class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
@override
ConfigProfileSections capture({
required ConnectionProvider connectionProvider,
required ChannelsProvider channelsProvider,
}) {
return const ConfigProfileSections();
}
}
class _FakeConnectionProvider implements ConnectionProvider {
_FakeConnectionProvider({DeviceInfo? deviceInfo})
: deviceInfo = deviceInfo ?? DeviceInfo();
int disconnectCallCount = 0;
@override
final DeviceInfo deviceInfo;
@override
ConnectionMode get connectionMode => ConnectionMode.ble;
@override
Future<void> disconnect() async {
disconnectCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeContactsProvider implements ContactsProvider {
@override
Future<void> persistNow() async {}
@override
Future<void> reloadFromStorage({
String? namespace,
Uint8List? devicePublicKey,
}) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeMessagesProvider implements MessagesProvider {
@override
Future<void> persistNow() async {}
@override
Future<void> reloadFromStorage({String? namespace}) async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeMapProvider implements MapProvider {
@override
Future<void> reloadProfileScopedState() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeDrawingProvider implements DrawingProvider {
@override
Future<void> reloadProfileScopedState() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeSensorsProvider implements SensorsProvider {
@override
Future<void> reloadProfileScopedState() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeChannelsProvider implements ChannelsProvider {
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeVoiceProvider implements VoiceProvider {
int reloadCallCount = 0;
@override
Future<void> reloadProfileScopedState() async {
reloadCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeImageProvider implements ip.ImageProvider {
int reloadCallCount = 0;
@override
Future<void> reloadProfileScopedState() async {
reloadCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeAppProvider implements AppProvider {
@override
Future<void> reloadProfileScopedSettings() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -0,0 +1,60 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/relay_candidate_sorter.dart';
void main() {
test('sorts relay candidates by distance from self point', () {
final sorter = RelayCandidateSorter();
final sorted = sorter.sortByDistanceFromSelf([
_contact(seed: 1, name: 'Far', lat: 46.08, lon: 14.60),
_contact(seed: 2, name: 'Near', lat: 46.0570, lon: 14.5060),
_contact(seed: 3, name: 'Middle', lat: 46.06, lon: 14.52),
], selfPoint: const LatLng(46.0569, 14.5058));
expect(sorted.map((contact) => contact.displayName).toList(), [
'Near',
'Middle',
'Far',
]);
});
test('falls back to stable name ordering when self point is unavailable', () {
final sorter = RelayCandidateSorter();
final sorted = sorter.sortByDistanceFromSelf([
_contact(seed: 1, name: 'Zulu', lat: 46.08, lon: 14.60),
_contact(seed: 2, name: 'Alpha', lat: 46.0570, lon: 14.5060),
], selfPoint: null);
expect(sorted.map((contact) => contact.displayName).toList(), [
'Alpha',
'Zulu',
]);
});
}
Contact _contact({
required int seed,
required String name,
required double lat,
required double lon,
}) {
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
return Contact(
publicKey: publicKey,
type: ContactType.repeater,
flags: 0,
outPathLen: -1,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (lat * 1e6).round(),
advLon: (lon * 1e6).round(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}

View File

@@ -0,0 +1,280 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart';
BlePacketLog _log({
required DateTime timestamp,
required List<int> rawData,
int responseCode = 0x88,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: PacketDirection.rx,
responseCode: responseCode,
);
}
List<int> _routeRaw({
required int payloadType,
required int pathDescriptor,
List<int> pathBytes = const <int>[],
}) {
return <int>[
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...pathBytes,
];
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope(
profilesEnabled: true,
activeProfileId: 'alpha',
);
});
test('uploads fixed packet type and path mode counters', () async {
final capturedPayloads = <Map<String, dynamic>>[];
DateTime now = DateTime.utc(2026, 4, 3, 10, 6);
final service = TrafficStatsReportingService(
client: MockClient((request) async {
capturedPayloads.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => now,
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
expect(service.isEnabled, isTrue);
await service.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x01,
pathBytes: const <int>[0xC0],
),
),
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 10),
rawData: _routeRaw(
payloadType: 0x05,
pathDescriptor: 0x41,
pathBytes: const <int>[0xC0, 0x10],
),
),
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 15),
rawData: _routeRaw(
payloadType: 0x08,
pathDescriptor: 0x81,
pathBytes: const <int>[0xC0, 0x10, 0x63],
),
),
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 20),
rawData: _routeRaw(
payloadType: 0x01,
pathDescriptor: 0x00,
),
),
]);
expect(capturedPayloads, hasLength(1));
final payload = capturedPayloads.single;
final counts = payload['counts'] as Map<String, dynamic>;
expect(payload['deviceKey6'], 'a1b2c3d4e5f6');
expect(payload.containsKey('publicKey'), isFalse);
expect(payload.containsKey('location'), isFalse);
expect(counts['pt_04'], 1);
expect(counts['pt_05'], 1);
expect(counts['pt_08'], 1);
expect(counts['pt_01'], 1);
expect(counts['path_mode_1b'], 1);
expect(counts['path_mode_2b'], 1);
expect(counts['path_mode_3b'], 1);
expect(counts['path_mode_none'], 1);
expect(service.pendingUploadCount, 0);
service.dispose();
});
test('classifies malformed route descriptors as decode and path failures', () async {
final capturedPayloads = <Map<String, dynamic>>[];
final service = TrafficStatsReportingService(
client: MockClient((request) async {
capturedPayloads.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => DateTime.utc(2026, 4, 3, 10, 6),
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await service.setEnabled(true);
await service.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 30),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x41,
),
),
]);
final counts =
(capturedPayloads.single['counts'] as Map<String, dynamic>);
expect(counts['decode_fail'], 1);
expect(counts['path_mode_unknown'], 1);
service.dispose();
});
test('persists queue and retries deterministically', () async {
final requestBodies = <Map<String, dynamic>>[];
var shouldFail = true;
DateTime now = DateTime.utc(2026, 4, 3, 10, 6);
final failingService = TrafficStatsReportingService(
client: MockClient((request) async {
requestBodies.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
if (shouldFail) {
return http.Response('nope', 503);
}
return http.Response('{}', 200);
}),
now: () => now,
appVersionProvider: () async => '2026.0402.1+44',
);
await failingService.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await failingService.setEnabled(true);
await failingService.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x01,
pathBytes: const <int>[0xC0],
),
),
]);
expect(failingService.pendingUploadCount, 1);
expect(failingService.lastError, 'Upload failed (503)');
final prefs = await SharedPreferences.getInstance();
expect(
prefs.containsKey('traffic_stats_reporting_queue'),
isTrue,
);
expect(
requestBodies.single['reportId'],
'a1b2c3d4e5f6:2026-04-03T10:00:00.000Z',
);
failingService.dispose();
shouldFail = false;
now = DateTime.utc(2026, 4, 3, 10, 7);
final retryService = TrafficStatsReportingService(
client: MockClient((request) async {
requestBodies.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => now,
appVersionProvider: () async => '2026.0402.1+44',
);
await retryService.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await retryService.flushPendingUploads();
expect(retryService.pendingUploadCount, 0);
expect(retryService.lastError, isNull);
expect(retryService.lastSuccessAt, now);
retryService.dispose();
});
test('ignores legacy interval preferences and keeps 5 minute windows', () async {
SharedPreferences.setMockInitialValues({
'traffic_stats_reporting_interval_minutes': 15,
});
final capturedPayloads = <Map<String, dynamic>>[];
final service = TrafficStatsReportingService(
client: MockClient((request) async {
capturedPayloads.add(
jsonDecode(request.body) as Map<String, dynamic>,
);
return http.Response('{}', 200);
}),
now: () => DateTime.utc(2026, 4, 3, 10, 6),
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await service.setEnabled(true);
await service.processLogs(<BlePacketLog>[
_log(
timestamp: DateTime.utc(2026, 4, 3, 10, 0, 5),
rawData: _routeRaw(
payloadType: 0x04,
pathDescriptor: 0x01,
pathBytes: const <int>[0xC0],
),
),
]);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('traffic_stats_reporting_enabled'), isTrue);
expect(prefs.containsKey('traffic_stats_reporting_interval_minutes'), isFalse);
expect(
prefs.containsKey('profile.alpha.traffic_stats_reporting_enabled'),
isFalse,
);
expect(
prefs.containsKey(
'profile.alpha.traffic_stats_reporting_interval_minutes',
),
isFalse,
);
expect(service.intervalMinutes, 5);
expect(capturedPayloads, hasLength(1));
service.dispose();
});
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/avatar_label_helper.dart';
void main() {
group('AvatarLabelHelper.buildLabel', () {
test('returns two initials for multi-word names', () {
expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS');
});
test('returns first two characters for single-word names', () {
expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL');
});
test('allows three compact characters for hash-prefixed names', () {
expect(AvatarLabelHelper.buildLabel('#ops'), '#OP');
});
test('removes spacing after hash-prefixed names', () {
expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO');
});
test('keeps non-hash labels at two characters', () {
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
});
test('strips emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
});
test('strips leading emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
});
});
}

View File

@@ -0,0 +1,364 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
void main() {
Contact buildContact({
required int seed,
required String name,
required DateTime lastSeen,
}) {
return Contact(
publicKey: Uint8List.fromList(
List<int>.generate(32, (index) => (seed + index) % 255),
),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000,
);
}
group('ContactGrouping.inferGroups', () {
test(
'groups prefixed contacts only when at least four share the prefix',
() {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'OTHER',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(groups, hasLength(1));
expect(groups.first.label, 'SI-');
expect(groups.first.contacts.map((contact) => contact.displayName), [
'SI-1',
'SI-2',
'SI-3',
'SI-4',
]);
},
);
test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
]);
expect(groups, isEmpty);
});
test('orders groups by latest last seen', () {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups([
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact(
seed: 3,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'HR-1',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 7,
name: 'HR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 8,
name: 'HR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 9,
name: 'HR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
]);
expect(groups, hasLength(2));
expect(groups.first.label, 'SI-');
expect(groups.last.label, 'HR-');
});
test('collapses extra auto-groups into a shared parent prefix', () {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups(
[
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
buildContact(
seed: 2,
name: 'AL-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'AL-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'AL-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'BR-1',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'BR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 7,
name: 'BR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 8,
name: 'BR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
buildContact(
seed: 9,
name: 'HU-PE-1',
lastSeen: now.subtract(const Duration(minutes: 8)),
),
buildContact(
seed: 10,
name: 'HU-PE-2',
lastSeen: now.subtract(const Duration(minutes: 9)),
),
buildContact(
seed: 11,
name: 'HU-GA-1',
lastSeen: now.subtract(const Duration(minutes: 10)),
),
buildContact(
seed: 12,
name: 'HU-GA-2',
lastSeen: now.subtract(const Duration(minutes: 11)),
),
buildContact(
seed: 13,
name: 'HU-PE-3',
lastSeen: now.subtract(const Duration(minutes: 12)),
),
buildContact(
seed: 14,
name: 'HU-PE-4',
lastSeen: now.subtract(const Duration(minutes: 13)),
),
buildContact(
seed: 15,
name: 'HU-GA-3',
lastSeen: now.subtract(const Duration(minutes: 14)),
),
buildContact(
seed: 16,
name: 'HU-GA-4',
lastSeen: now.subtract(const Duration(minutes: 15)),
),
],
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
);
expect(groups, hasLength(3));
expect(groups[0].label, 'AL-');
expect(groups[1].label, 'BR-');
expect(groups[2].label, 'HU-');
expect(groups[2].contacts.map((contact) => contact.displayName), [
'HU-PE-1',
'HU-PE-2',
'HU-GA-1',
'HU-GA-2',
'HU-PE-3',
'HU-PE-4',
'HU-GA-3',
'HU-GA-4',
]);
expect(groups[2].matchPrefixes, ['HU-']);
});
test(
'does not create an Others bucket when overflow has no shared parent',
() {
final now = DateTime(2026, 3, 10, 12);
final groups = ContactGrouping.inferGroups(
[
buildContact(seed: 1, name: 'AL-1', lastSeen: now),
buildContact(
seed: 2,
name: 'AL-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'AL-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'AL-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'BR-1',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
buildContact(
seed: 6,
name: 'BR-2',
lastSeen: now.subtract(const Duration(minutes: 5)),
),
buildContact(
seed: 7,
name: 'BR-3',
lastSeen: now.subtract(const Duration(minutes: 6)),
),
buildContact(
seed: 8,
name: 'BR-4',
lastSeen: now.subtract(const Duration(minutes: 7)),
),
buildContact(
seed: 9,
name: 'CR-1',
lastSeen: now.subtract(const Duration(minutes: 8)),
),
buildContact(
seed: 10,
name: 'CR-2',
lastSeen: now.subtract(const Duration(minutes: 9)),
),
buildContact(
seed: 11,
name: 'CR-3',
lastSeen: now.subtract(const Duration(minutes: 10)),
),
buildContact(
seed: 12,
name: 'CR-4',
lastSeen: now.subtract(const Duration(minutes: 11)),
),
],
maxNamedGroups: 2,
overflowGroupLabel: 'Others',
);
expect(groups.map((group) => group.label), ['AL-', 'BR-', 'CR-']);
},
);
});
group('ContactGrouping.contactMatchesInferredGroupLabel', () {
test('matches the inferred auto-group label for prefixed contacts', () {
final contact = buildContact(
seed: 99,
name: 'SI-1',
lastSeen: DateTime(2026, 3, 10, 12),
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI-'),
isTrue,
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'si'),
isTrue,
);
});
test('returns false when a contact has no inferred auto-group label', () {
final contact = buildContact(
seed: 100,
name: 'Lone Contact',
lastSeen: DateTime(2026, 3, 10, 12),
);
expect(
ContactGrouping.contactMatchesInferredGroupLabel(contact, 'SI'),
isFalse,
);
});
test('finds a shared inferred label for related repeater names', () {
final contacts = [
buildContact(
seed: 101,
name: 'HU-PE',
lastSeen: DateTime(2026, 3, 10, 12),
),
buildContact(
seed: 102,
name: 'HU-GA',
lastSeen: DateTime(2026, 3, 10, 11, 59),
),
];
expect(ContactGrouping.sharedInferredGroupLabel(contacts), 'HU-');
});
test('finds a shared parent label for related subgroup prefixes', () {
expect(
ContactGrouping.sharedParentGroupLabel(['HU-PE-', 'HU-GA-']),
'HU-',
);
expect(ContactGrouping.sharedParentGroupLabel(['AL-', 'BR-']), isNull);
});
});
}

View File

@@ -0,0 +1,354 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/map_drawing.dart';
import 'package:meshcore_sar_app/models/map_coordinate_space.dart';
import 'package:meshcore_sar_app/utils/drawing_message_parser.dart';
void main() {
group('DrawingMessageParser - Critical String Formatting Tests', () {
test('createDrawingMessage returns raw string, not Object', () {
final drawing = LineDrawing(
id: 'test-123',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [LatLng(37.7749, -122.4194), LatLng(37.7750, -122.4195)],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
// CRITICAL: Must be a String type
expect(message, isA<String>());
// CRITICAL: Must NOT contain "Instance of" or "Object"
expect(message, isNot(contains('Instance of')));
expect(message, isNot(contains('Object')));
// CRITICAL: Must start with D: prefix
expect(message, startsWith('D:'));
// CRITICAL: Must be valid JSON after prefix
final jsonPart = message.substring(2);
expect(() => jsonPart, returnsNormally);
});
test('createDrawingMessage produces parseable JSON string', () {
final drawing = LineDrawing(
id: 'test-456',
color: DrawingColors.palette[2], // green
createdAt: DateTime.now(),
points: [
LatLng(40.7128, -74.0060),
LatLng(40.7129, -74.0061),
LatLng(40.7130, -74.0062),
],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
// Should be parseable back
final parsed = DrawingMessageParser.parseDrawingMessage(
message,
senderName: 'Test Sender',
messageId: 'msg-123',
);
expect(parsed, isNotNull);
expect(parsed, isA<LineDrawing>());
expect((parsed as LineDrawing).points.length, equals(3));
});
test(
'createDrawingMessage handles rectangle with proper string format',
() {
final drawing = RectangleDrawing(
id: 'rect-789',
color: DrawingColors.palette[4], // orange
createdAt: DateTime.now(),
topLeft: LatLng(45.5231, -122.6765),
bottomRight: LatLng(45.5100, -122.6600),
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
// CRITICAL: Must be pure string
expect(message, isA<String>());
expect(message, startsWith('D:'));
// Should contain compact JSON format
expect(message, contains('"t":'));
expect(message, contains('"c":'));
expect(message, contains('"b":'));
// Must NOT contain any object representations
expect(message, isNot(contains('RectangleDrawing')));
expect(message, isNot(contains('Instance')));
},
);
test('custom-map drawings use D2 format and preserve map metadata', () {
final drawing = LineDrawing(
id: 'custom-1',
color: DrawingColors.palette[1],
createdAt: DateTime.now(),
points: [LatLng(120, 200), LatLng(320, 450)],
coordinateSpace: MapCoordinateSpace.customMap,
mapId: '1234567890abcdef',
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
expect(message, startsWith('D2:'));
final parsed = DrawingMessageParser.parseDrawingMessage(message);
expect(parsed, isA<LineDrawing>());
expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap);
expect(parsed.mapId, '1234567890ab');
final parsedLine = parsed as LineDrawing;
expect(parsedLine.points.first.latitude, 120);
expect(parsedLine.points.first.longitude, 200);
});
test('JSON encoding produces string with coordinates as numbers', () {
final drawing = LineDrawing(
id: 'coord-test',
color: DrawingColors.palette[1], // blue
createdAt: DateTime.now(),
points: [LatLng(37.77490, -122.41940)],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
// Extract JSON part
final jsonStr = message.substring(2);
// CRITICAL: Coordinates must be numbers, not strings
// Format: {"t":0,"c":1,"p":[37.7749,-122.4194]}
expect(jsonStr, contains('37.7749'));
expect(jsonStr, contains('-122.4194'));
// Should NOT have coordinates as quoted strings
expect(jsonStr, isNot(contains('"37.7749"')));
expect(jsonStr, isNot(contains('"-122.4194"')));
});
test('isDrawingMessage correctly identifies valid drawing messages', () {
expect(
DrawingMessageParser.isDrawingMessage('D:{"t":0,"c":1,"p":[1,2]}'),
isTrue,
);
expect(DrawingMessageParser.isDrawingMessage('S:🧑:37,-122'), isFalse);
expect(DrawingMessageParser.isDrawingMessage('Plain text'), isFalse);
expect(DrawingMessageParser.isDrawingMessage('D:'), isTrue);
});
test('parseDrawingMessage returns null for malformed messages', () {
expect(DrawingMessageParser.parseDrawingMessage('Not a drawing'), isNull);
expect(
DrawingMessageParser.parseDrawingMessage('D:invalid json'),
isNull,
);
});
test('round-trip: create -> parse -> create produces consistent output', () {
final original = LineDrawing(
id: 'roundtrip-1',
color: DrawingColors.palette[3], // yellow
createdAt: DateTime.now(),
points: [LatLng(51.5074, -0.1278), LatLng(51.5075, -0.1279)],
);
// Create message
final message1 = DrawingMessageParser.createDrawingMessage(original);
// Parse it
final parsed = DrawingMessageParser.parseDrawingMessage(
message1,
senderName: 'TestUser',
messageId: 'msg-rt1',
);
expect(parsed, isNotNull);
// Create message again from parsed
final message2 = DrawingMessageParser.createDrawingMessage(parsed!);
// Both messages should have same structure (excluding metadata like IDs)
expect(message1.substring(0, 10), equals(message2.substring(0, 10)));
});
test('color indices are preserved as integers in JSON', () {
for (int i = 0; i < DrawingColors.palette.length; i++) {
final drawing = LineDrawing(
id: 'color-$i',
color: DrawingColors.palette[i],
createdAt: DateTime.now(),
points: [LatLng(0, 0)],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
final jsonStr = message.substring(2);
// Color index should be integer in JSON
expect(jsonStr, contains('"c":$i'));
}
});
test('type indices are preserved correctly', () {
// Line = type 0
final line = LineDrawing(
id: 'line-type',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [LatLng(1, 1), LatLng(2, 2)],
);
final lineMsg = DrawingMessageParser.createDrawingMessage(line);
expect(lineMsg, contains('"t":0'));
// Rectangle = type 1
final rect = RectangleDrawing(
id: 'rect-type',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
topLeft: LatLng(1, 1),
bottomRight: LatLng(2, 2),
);
final rectMsg = DrawingMessageParser.createDrawingMessage(rect);
expect(rectMsg, contains('"t":1'));
});
test('coordinates are rounded to 5 decimal places', () {
final drawing = LineDrawing(
id: 'precision-test',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [LatLng(37.774901234567, -122.419401234567)],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
final jsonStr = message.substring(2);
// Should be rounded to 5 decimals
expect(jsonStr, contains('37.7749'));
expect(jsonStr, contains('-122.4194'));
// Should NOT contain extra precision
expect(jsonStr, isNot(contains('37.774901234567')));
});
test('empty points array produces valid message', () {
final drawing = LineDrawing(
id: 'empty-line',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
expect(message, isA<String>());
expect(message, startsWith('D:'));
expect(message, contains('"p":[]'));
});
test('large coordinate values are handled correctly', () {
final drawing = LineDrawing(
id: 'large-coords',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [
LatLng(89.99999, 179.99999), // Near max valid coords
LatLng(-89.99999, -179.99999), // Near min valid coords
],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
expect(message, isA<String>());
expect(message, contains('89.99999'));
expect(message, contains('179.99999'));
expect(message, contains('-89.99999'));
expect(message, contains('-179.99999'));
});
test('getDrawingTypeDisplay returns correct type names', () {
final lineMsg = 'D:{"t":0,"c":1,"p":[1,2,3,4]}';
final rectMsg = 'D:{"t":1,"c":2,"b":[1,2,3,4]}';
expect(
DrawingMessageParser.getDrawingTypeDisplay(lineMsg),
equals('Line'),
);
expect(
DrawingMessageParser.getDrawingTypeDisplay(rectMsg),
equals('Rectangle'),
);
expect(DrawingMessageParser.getDrawingTypeDisplay('Invalid'), isNull);
});
test('getColorName returns correct color names', () {
for (int i = 0; i < 8; i++) {
final msg = 'D:{"t":0,"c":$i,"p":[0,0]}';
final colorName = DrawingMessageParser.getColorName(msg);
expect(colorName, isNotNull);
expect(colorName, isA<String>());
}
expect(DrawingMessageParser.getColorName('Invalid'), isNull);
});
test('getDrawingMetadata extracts correct information', () {
final lineMsg = 'D:{"t":0,"c":2,"p":[1,2,3,4,5,6]}';
final metadata = DrawingMessageParser.getDrawingMetadata(lineMsg);
expect(metadata, isNotNull);
expect(metadata!['type'], equals('Line'));
expect(metadata['color'], equals('Green'));
expect(metadata['pointCount'], equals(3)); // 6 values = 3 points
});
test('message does not contain sender metadata in JSON', () {
final drawing = LineDrawing(
id: 'no-sender',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [LatLng(1, 2)],
senderName: 'TestSender', // Should NOT be in network JSON
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
// CRITICAL: Sender name should NOT be in the message
expect(message, isNot(contains('sender')));
expect(message, isNot(contains('TestSender')));
// Should only contain compact fields: t, c, p (or b)
final jsonStr = message.substring(2);
expect(jsonStr, contains('"t":'));
expect(jsonStr, contains('"c":'));
expect(jsonStr, matches(RegExp(r'"p":|"b":')));
});
test('special characters in metadata do not break format', () {
// Even though sender name isn't included in network JSON,
// test that it doesn't affect the message creation
final drawing = LineDrawing(
id: 'special-chars-"quotes"',
color: DrawingColors.palette[0],
createdAt: DateTime.now(),
points: [LatLng(1, 2)],
);
final message = DrawingMessageParser.createDrawingMessage(drawing);
expect(message, isA<String>());
expect(message, startsWith('D:'));
// Should still be parseable
expect(
() => DrawingMessageParser.parseDrawingMessage(message),
returnsNormally,
);
});
});
}

View File

@@ -0,0 +1,122 @@
import 'dart:typed_data';
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
void main() {
group('FastGpsPacket', () {
test('encodes and parses a valid 16-byte packet', () {
final packet = FastGpsPacket(
senderKey6: 'aabbccddeeff',
latitude: 46.0569,
longitude: 14.5058,
speedKmh: 37,
);
final encoded = packet.encodeBinary();
expect(encoded.length, equals(16));
expect(encoded[0], equals(FastGpsPacket.magic));
final parsed = FastGpsPacket.tryParseBinary(encoded);
expect(parsed, isNotNull);
expect(parsed!.senderKey6, equals('aabbccddeeff'));
expect(parsed.latitude, closeTo(46.0569, 0.000001));
expect(parsed.longitude, closeTo(14.5058, 0.000001));
expect(parsed.speedKmh, equals(37));
});
test('clamps speed to a single unsigned byte', () {
final packet = FastGpsPacket(
senderKey6: '001122334455',
latitude: 1.0,
longitude: 2.0,
speedKmh: 999,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
expect(parsed, isNotNull);
expect(parsed!.speedKmh, equals(255));
});
test('supports negative coordinates', () {
final packet = FastGpsPacket(
senderKey6: '001122334455',
latitude: -33.8688,
longitude: -151.2093,
speedKmh: 0,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
expect(parsed, isNotNull);
expect(parsed!.latitude, closeTo(-33.8688, 0.000001));
expect(parsed.longitude, closeTo(-151.2093, 0.000001));
});
test('rejects malformed payloads', () {
// Too short.
expect(
FastGpsPacket.tryParseBinary(Uint8List.fromList([0x47, 0x01])),
isNull,
);
// Wrong magic at the correct length.
expect(
FastGpsPacket.tryParseBinary(
Uint8List.fromList(List<int>.filled(16, 0)..[0] = 0x48),
),
isNull,
);
// Legacy 19-byte format is no longer accepted.
expect(
FastGpsPacket.tryParseBinary(
Uint8List.fromList(List<int>.filled(19, 0)..[0] = 0x47),
),
isNull,
);
});
test('rejects invalid coordinate ranges', () {
final payload = Uint8List(16);
payload[0] = FastGpsPacket.magic;
payload.setRange(1, 7, [0, 1, 2, 3, 4, 5]);
final data = ByteData.sublistView(payload);
data.setInt32(
7,
(91.0 * FastGpsPacket.coordinateScale).round(),
Endian.little,
);
data.setInt32(
11,
(14.5 * FastGpsPacket.coordinateScale).round(),
Endian.little,
);
data.setUint8(15, 1);
expect(FastGpsPacket.tryParseBinary(payload), isNull);
});
test('preserves at least meter accuracy', () {
const latitude = 46.0569123;
const longitude = 14.5058123;
final packet = FastGpsPacket(
senderKey6: 'aabbccddeeff',
latitude: latitude,
longitude: longitude,
speedKmh: 5,
);
final parsed = FastGpsPacket.tryParseBinary(packet.encodeBinary());
expect(parsed, isNotNull);
final latMeters = (parsed!.latitude - latitude).abs() * 111320.0;
final lonMeters =
(parsed.longitude - longitude).abs() *
111320.0 *
math.cos(latitude * math.pi / 180.0);
expect(latMeters, lessThan(1.0));
expect(lonMeters, lessThan(1.0));
});
});
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
void main() {
group('ImageEnvelope', () {
test('encodes and parses IE4 with compressed session id', () {
final env = ImageEnvelope(
sessionId: '0000000a',
format: ImageFormat.avif,
total: 14,
width: 256,
height: 171,
sizeBytes: 2100,
);
final text = env.encode();
expect(text.startsWith('IE4:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = ImageEnvelope.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.format, equals(ImageFormat.avif));
expect(parsed.total, equals(14));
expect(parsed.width, equals(256));
expect(parsed.height, equals(171));
expect(parsed.sizeBytes, equals(2100));
expect(parsed.version, equals(4));
});
test('rejects IE1 legacy prefix', () {
const legacy = 'IE1:deadbeef:0:7:128:128:1050:aabbccddeeff:1700000000:1';
expect(ImageEnvelope.tryParse(legacy), isNull);
});
});
group('ImageFetchRequest', () {
test('encodes and parses IR4 with compressed sid', () {
final req = ImageFetchRequest(
sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa',
);
final text = req.encode();
expect(text.startsWith('IR4:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = ImageFetchRequest.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(4));
});
test('encodes and parses compact missing index ranges', () {
final req = ImageFetchRequest(
sessionId: '0000000a',
want: 'missing',
missingIndices: const [0, 1, 2, 5, 6, 8],
requesterKey6: 'ffeeddccbbaa',
);
final text = req.encode();
expect(text, contains(':m0-2.5-6.8:'));
final parsed = ImageFetchRequest.tryParse(text);
expect(parsed, isNotNull);
expect(parsed!.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 1, 2, 5, 6, 8]));
});
test('rejects IR1 legacy prefix', () {
const legacy = 'IR1:00112233:a:ffeeddccbbaa:1700000001:1';
expect(ImageFetchRequest.tryParse(legacy), isNull);
});
test('encodes and parses binary fetch request', () {
final req = ImageFetchRequest(
sessionId: '01020304',
want: 'missing',
missingIndices: const [0, 2, 5],
requesterKey6: 'ffeeddccbbaa',
);
final payload = req.encodeBinary();
expect(ImageFetchRequest.isRequestBinary(payload), isTrue);
final parsed = ImageFetchRequest.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 2, 5]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(4));
});
});
group('ImageFragmentAck', () {
test('encodes and parses binary ack', () {
final ack = ImageFragmentAck(sessionId: '01020304', index: 9);
final payload = ack.encodeBinary();
expect(ImageFragmentAck.isImageFragmentAckBinary(payload), isTrue);
final parsed = ImageFragmentAck.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.index, equals(9));
});
});
group('safeImageDataBytesForPath', () {
test('caps direct-route fragments to conservative default size', () {
expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes));
});
test('shrinks for longer paths but never exceeds conservative default', () {
expect(
safeImageDataBytesForPath(2),
lessThanOrEqualTo(ImagePacket.maxDataBytes),
);
});
});
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/link_quality.dart';
void main() {
group('linkQualityLabel', () {
test('classifies SNR-only values without forcing them to weak', () {
expect(linkQualityLabel(null, 12.0), 'Excellent');
expect(linkQualityLabel(null, 6.0), 'Good');
expect(linkQualityLabel(null, 1.0), 'Fair');
expect(linkQualityLabel(null, -6.0), 'Weak');
});
test('classifies RSSI-only values using direct thresholds', () {
expect(linkQualityLabel(-58, null), 'Excellent');
expect(linkQualityLabel(-68, null), 'Good');
expect(linkQualityLabel(-78, null), 'Fair');
expect(linkQualityLabel(-92, null), 'Weak');
});
test('averages mixed metrics when both are available', () {
expect(linkQualityLabel(-72, 11.0), 'Good');
expect(linkQualityLabel(-85, 7.0), 'Fair');
});
});
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/location_formats.dart';
void main() {
group('formatUtm', () {
test('formats northern hemisphere UTM coordinates', () {
expect(formatUtm(37.7749, -122.4194), '10S 551131E 4180999N');
});
test('formats southern hemisphere UTM coordinates', () {
expect(formatUtm(-33.8688, 151.2093), '56H 334369E 6250948N');
});
test('falls back to decimal outside the UTM latitude range', () {
expect(formatUtm(85, 14.5), '85.00000, 14.50000');
});
});
group('formatCoordinates', () {
test('formats DMS coordinates', () {
expect(
formatCoordinates(
46.0569,
14.5058,
CoordinateDisplayFormat.dms,
),
'46°03\'24.84"N 14°30\'20.88"E',
);
});
});
}

View File

@@ -0,0 +1,179 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/log_rx_route_decoder.dart';
void main() {
group('LogRxRouteDecoder.decode', () {
test('parses route and sender from LOG_RX_DATA packet', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x04,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01);
expect(decoded.pathDescriptor, 0x04);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 1);
expect(decoded.hopHashes, ['c2', 'ba', '5f', 'de']);
expect(decoded.originalSenderHashHex, 'c2');
});
test('parses legacy two byte paths as two one-byte hops', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x02,
0xc2,
0xba,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.pathDescriptor, 0x02);
expect(decoded.pathBytes, [0xc2, 0xba]);
expect(decoded.hashSize, 1);
expect(decoded.hopCount, 2);
expect(decoded.hopHashes, ['c2', 'ba']);
});
test('parses encoded descriptor with 2-byte hashes', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x42,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.pathDescriptor, 0x42);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopCount, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
});
test('uses one byte hashes for legacy packet lengths', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x09,
0x06,
0xaa,
0xbb,
0xcc,
0xdd,
0xee,
0xff,
]);
final decoded = LogRxRouteDecoder.decode(packet, preferredHashSize: 2);
expect(decoded, isNotNull);
expect(decoded!.hashSize, 1);
expect(decoded.hopHashes, ['aa', 'bb', 'cc', 'dd', 'ee', 'ff']);
});
});
group('LogRxRouteDecoder.resolveHash', () {
test('prefers own node when hash matches device key', () {
final resolved = LogRxRouteDecoder.resolveHash(
'c201',
contacts: const [],
ownPublicKey: Uint8List.fromList([0xc2, 0x01, 0x02]),
ownName: 'Base',
);
expect(resolved.isOwnNode, isTrue);
expect(resolved.label, 'Base (you)');
});
test('resolves unique contact by first public key byte', () {
final resolved = LogRxRouteDecoder.resolveHash(
'c211',
contacts: [
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
],
);
expect(resolved.isUniqueMatch, isTrue);
expect(resolved.label, 'Alpha');
});
test('marks ambiguous matches without pretending certainty', () {
final resolved = LogRxRouteDecoder.resolveHash(
'c2',
contacts: [
_contact(name: 'Alpha', keyPrefix: [0xc2, 0x11]),
_contact(name: 'Bravo', keyPrefix: [0xc2, 0x22]),
],
);
expect(resolved.isUniqueMatch, isFalse);
expect(resolved.matchCount, 2);
});
});
group('LogRxRouteDecoder.reverseHopBytes', () {
test('reverses path by hop size', () {
final reversed = LogRxRouteDecoder.reverseHopBytes([
0xc2,
0xba,
0x5f,
0xde,
0xaa,
0xbb,
], hashSize: 2);
expect(reversed, [0xaa, 0xbb, 0x5f, 0xde, 0xc2, 0xba]);
});
});
}
Contact _contact({required String name, required List<int> keyPrefix}) {
return Contact(
publicKey: Uint8List.fromList([
...keyPrefix,
0x11,
0x22,
0x33,
0x44,
0x55,
0x66,
0x77,
]),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/media_swarm_protocol.dart';
void main() {
group('MediaSwarmProtocol', () {
test('encodes and decodes binary missing-fragment requests', () {
const request = MediaSwarmRequest(
mediaType: 'image',
sessionId: 'deadbeef',
requesterKey6: 'aabbccddeeff',
missingIndices: [9, 2, 9, 0],
);
final decoded = MediaSwarmRequest.tryParseBinary(request.encodeBinary());
expect(decoded, isNotNull);
expect(decoded!.mediaType, 'image');
expect(decoded.sessionId, 'deadbeef');
expect(decoded.requesterKey6, 'aabbccddeeff');
expect(decoded.missingIndices, [0, 2, 9]);
expect(decoded.requestsAll, isFalse);
});
test('encodes and decodes binary availability advertisements', () {
const availability = MediaSwarmAvailability(
mediaType: 'voice',
sessionId: '01020304',
requesterKey6: 'aabbccddeeff',
responderKey6: '112233445566',
availableIndices: [7, 1],
);
final decoded = MediaSwarmAvailability.tryParseBinary(
availability.encodeBinary(),
);
expect(decoded, isNotNull);
expect(decoded!.mediaType, 'voice');
expect(decoded.sessionId, '01020304');
expect(decoded.requesterKey6, 'aabbccddeeff');
expect(decoded.responderKey6, '112233445566');
expect(decoded.availableIndices, [1, 7]);
expect(decoded.servesAll, isFalse);
});
test('uses zero-count semantics when no indices are provided', () {
const request = MediaSwarmRequest(
mediaType: 'voice',
sessionId: '01020304',
requesterKey6: 'aabbccddeeff',
);
const availability = MediaSwarmAvailability(
mediaType: 'image',
sessionId: 'deadbeef',
requesterKey6: 'aabbccddeeff',
responderKey6: '112233445566',
availableIndices: [],
);
expect(
MediaSwarmRequest.tryParseBinary(request.encodeBinary())?.requestsAll,
isTrue,
);
expect(
MediaSwarmAvailability.tryParseBinary(
availability.encodeBinary(),
)?.servesAll,
isTrue,
);
});
});
}

View File

@@ -0,0 +1,442 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/sar_marker.dart';
import 'package:meshcore_sar_app/models/map_coordinate_space.dart';
import 'package:meshcore_sar_app/utils/sar_message_parser.dart';
void main() {
group('SarMessageParser - Critical String Formatting Tests', () {
test('createSarMessage returns raw string, not Object', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(37.7749, -122.4194),
colorIndex: 2,
);
// CRITICAL: Must be a String type
expect(message, isA<String>());
// CRITICAL: Must NOT contain "Instance of" or "Object"
expect(message, isNot(contains('Instance of')));
expect(message, isNot(contains('Object')));
expect(message, isNot(contains('LatLng')));
// CRITICAL: Must start with S: prefix
expect(message, startsWith('S:'));
});
test('createSarMessage produces correct format with all components', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(37.7749, -122.4194),
notes: 'Found alive',
colorIndex: 2,
);
// New format: S:<emoji>:<colorIndex>:<lat>,<lon>:<notes>
expect(message, startsWith('S:'));
expect(message, contains('🧑')); // or 👤
expect(message, contains(':2:')); // color index
expect(message, contains('37.7749'));
expect(message, contains('-122.4194'));
expect(message, contains('Found alive'));
});
test('coordinates are converted to strings properly', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.fire,
location: LatLng(40.7128, -74.0060),
colorIndex: 0,
);
// CRITICAL: Coordinates must be in string form, not Object
expect(message, contains('40.7128'));
expect(
message,
contains('-74.006'),
); // Trailing zero trimmed by toString()
// Must NOT contain object representation
expect(message, isNot(contains('LatLng')));
expect(message, isNot(contains('latitude:')));
expect(message, isNot(contains('longitude:')));
});
test('colorIndex is converted to string in format', () {
for (int i = 0; i <= 7; i++) {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.stagingArea,
location: LatLng(0, 0),
colorIndex: i,
);
// Color index should be in string
expect(message, contains(':$i:'));
expect(message, isA<String>());
}
});
test('null colorIndex defaults to 0', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(1, 1),
colorIndex: null,
);
// Should default to color index 0
expect(message, contains(':0:'));
});
test('isSarMessage correctly identifies SAR messages', () {
expect(SarMessageParser.isSarMessage('S:🧑:2:37.7,-122.4'), isTrue);
expect(SarMessageParser.isSarMessage('S:🔥:0:40.7,-74.0:Fire'), isTrue);
expect(SarMessageParser.isSarMessage('D:{"t":0}'), isFalse);
expect(SarMessageParser.isSarMessage('Plain text'), isFalse);
});
test('parse correctly extracts all components', () {
final message = 'S:🧑:2:37.7749,-122.4194:Found alive';
final info = SarMessageParser.parse(message);
expect(info, isNotNull);
expect(info!.type, equals(SarMarkerType.foundPerson));
expect(info.location.latitude, equals(37.7749));
expect(info.location.longitude, equals(-122.4194));
expect(info.colorIndex, equals(2));
expect(info.notes, contains('Found alive'));
});
test('parse handles old format without color index', () {
// Old format: S:<emoji>:<lat>,<lon>:<notes>
final message = 'S:🔥:40.7128,-74.0060:Large fire';
final info = SarMessageParser.parse(message);
expect(info, isNotNull);
expect(info!.type, equals(SarMarkerType.fire));
expect(info.location.latitude, equals(40.7128));
expect(info.location.longitude, equals(-74.0060));
expect(info.colorIndex, isNull); // Old format has no color index
expect(info.notes, equals('Large fire'));
});
test('custom-map SAR markers use S2 format and preserve map metadata', () {
final message = SarMessageParser.createCustomMapSarMessage(
emoji: '📦',
mapId: 'abcdef1234567890',
point: LatLng(250, 400),
notes: 'Cache location',
colorIndex: 4,
);
expect(message, startsWith('S2:'));
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
expect(parsed!.coordinateSpace, MapCoordinateSpace.customMap);
expect(parsed.mapId, 'abcdef123456');
expect(parsed.location.latitude, 250);
expect(parsed.location.longitude, 400);
expect(parsed.notes, 'Cache location');
});
test('round-trip: create -> parse -> create preserves format', () {
final original = SarMessageParser.createSarMessage(
type: SarMarkerType.stagingArea,
location: LatLng(51.5074, -0.1278),
notes: 'Command center',
colorIndex: 4,
);
// Parse it
final parsed = SarMessageParser.parse(original);
expect(parsed, isNotNull);
// Create again
final recreated = SarMessageParser.createSarMessage(
type: parsed!.type,
location: parsed.location,
notes: parsed.notes,
colorIndex: parsed.colorIndex,
);
// Should be identical
expect(recreated, equals(original));
});
test('negative coordinates are handled correctly', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(-33.8688, -151.2093), // Sydney (negative coords)
colorIndex: 1,
);
expect(message, contains('-33.8688'));
expect(message, contains('-151.2093'));
// Should be parseable
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
expect(parsed!.location.latitude, equals(-33.8688));
expect(parsed.location.longitude, equals(-151.2093));
});
test('extreme valid coordinates work correctly', () {
// Test near max valid latitude/longitude
final message1 = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(89.99999, 179.99999),
colorIndex: 0,
);
expect(message1, contains('89.99999'));
expect(message1, contains('179.99999'));
// Test near min valid latitude/longitude
final message2 = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(-89.99999, -179.99999),
colorIndex: 0,
);
expect(message2, contains('-89.99999'));
expect(message2, contains('-179.99999'));
});
test('parse rejects invalid coordinates', () {
expect(SarMessageParser.parse('S:🧑:0:91.0,0'), isNull); // lat > 90
expect(SarMessageParser.parse('S:🧑:0:-91.0,0'), isNull); // lat < -90
expect(SarMessageParser.parse('S:🧑:0:0,181.0'), isNull); // lon > 180
expect(SarMessageParser.parse('S:🧑:0:0,-181.0'), isNull); // lon < -180
});
test('parse rejects invalid color indices', () {
// Color index should be 0-7
final message = 'S:🧑:9:37.7,-122.4'; // Invalid index 9
final info = SarMessageParser.parse(message);
expect(info, isNotNull);
expect(info!.colorIndex, isNull); // Should be ignored if invalid
});
test('notes with special characters are preserved', () {
final notes = 'Multi-line\nwith: colons, commas';
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(1, 2),
notes: notes,
colorIndex: 0,
);
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
expect(parsed!.notes, contains('Multi-line'));
expect(parsed.notes, contains('colons, commas'));
});
test('empty notes produce valid message', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.fire,
location: LatLng(1, 2),
notes: '',
colorIndex: 0,
);
// Should not have trailing colon
expect(message, isNot(endsWith(':')));
// Should be parseable
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
});
test('null notes produce valid message', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.stagingArea,
location: LatLng(1, 2),
notes: null,
colorIndex: 0,
);
// Should not have notes section
expect(message, isNot(endsWith(':')));
// Should be parseable
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
});
test('all emoji types produce valid strings', () {
final types = [
SarMarkerType.foundPerson,
SarMarkerType.fire,
SarMarkerType.stagingArea,
];
for (final type in types) {
final message = SarMessageParser.createSarMessage(
type: type,
location: LatLng(1, 2),
colorIndex: 0,
);
expect(message, isA<String>());
expect(message, startsWith('S:'));
expect(message, contains(type.emoji));
// Must not contain type name or object representation
expect(message, isNot(contains('SarMarkerType')));
expect(message, isNot(contains('Instance')));
}
});
test('isValidFormat correctly validates messages', () {
expect(
SarMessageParser.isValidFormat('S:🧑:2:37.7749,-122.4194'),
isTrue,
);
expect(SarMessageParser.isValidFormat('S:invalid:format'), isFalse);
expect(SarMessageParser.isValidFormat('Not SAR message'), isFalse);
});
test('getFormatError provides helpful error messages', () {
expect(
SarMessageParser.getFormatError('Not SAR'),
contains('must start with "S:"'),
);
expect(SarMessageParser.getFormatError('S:'), contains('Invalid format'));
expect(
SarMessageParser.getFormatError('S::37.7,-122.4'),
contains('Missing emoji'),
);
});
test('extractNotes correctly handles multi-line messages', () {
final message = 'S:🧑:0:37.7,-122.4:First line\nSecond line\nThird line';
final notes = SarMessageParser.extractNotes(message);
expect(notes, isNotNull);
expect(notes, contains('Second line'));
expect(notes, contains('Third line'));
});
test('message format is compact and efficient', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(37.7749, -122.4194),
colorIndex: 2,
);
// Should be very compact: S:🧑:2:37.7749,-122.4194
expect(message.length, lessThan(50));
// Should not have extra whitespace
expect(message, isNot(contains(' ')));
expect(message, isNot(contains('\n')));
});
test('coordinates maintain precision', () {
final precise = LatLng(37.774901234, -122.419401234);
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: precise,
colorIndex: 0,
);
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
// Should maintain reasonable precision (Dart's toString default)
expect(parsed!.location.latitude, closeTo(37.774901234, 0.000001));
expect(parsed.location.longitude, closeTo(-122.419401234, 0.000001));
});
test('zero coordinates work correctly', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.stagingArea,
location: LatLng(0.0, 0.0),
colorIndex: 0,
);
expect(message, contains('0.0,0.0'));
final parsed = SarMessageParser.parse(message);
expect(parsed, isNotNull);
expect(parsed!.location.latitude, equals(0.0));
expect(parsed.location.longitude, equals(0.0));
});
test('emoji is preserved as string, not code points', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(1, 2),
colorIndex: 0,
);
// Should contain actual emoji character
expect(message, contains('🧑'));
// Should NOT contain unicode code points or escape sequences
expect(message, isNot(contains(r'\u')));
expect(message, isNot(contains('U+')));
});
test('format is compatible with CLAUDE.md specification', () {
// According to CLAUDE.md:
// New format: S:<emoji>:<colorIndex>:<latitude>,<longitude>:<optional_message>
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.fire,
location: LatLng(40.7128, -74.0060),
notes: 'Large wildfire spreading rapidly',
colorIndex: 0,
);
// Should match: S:🔥:0:40.7128,-74.006:Large wildfire spreading rapidly
expect(message, startsWith('S:🔥:0:'));
expect(message, contains('40.7128,-74.006')); // Trailing zero trimmed
expect(message, endsWith('Large wildfire spreading rapidly'));
});
test('backward compatibility with old format', () {
// Old format without color index: S:<emoji>:<lat>,<lon>:<notes>
final oldMessage = 'S:🧑:37.7749,-122.4194:Found person';
final parsed = SarMessageParser.parse(oldMessage);
expect(parsed, isNotNull);
expect(parsed!.type, equals(SarMarkerType.foundPerson));
expect(parsed.colorIndex, isNull); // Old format has no color index
expect(parsed.notes, equals('Found person'));
});
test('new format with color index is preferred', () {
final message = SarMessageParser.createSarMessage(
type: SarMarkerType.foundPerson,
location: LatLng(1, 2),
colorIndex: 3,
);
// Should use new format with color index
expect(message, contains(':3:'));
expect(message, contains('🧑'));
});
test('toString produces human-readable output for SarMarkerInfo', () {
final info = SarMarkerInfo(
type: SarMarkerType.foundPerson,
location: LatLng(37.7749, -122.4194),
emoji: '🧑',
notes: 'Test notes',
colorIndex: 2,
coordinateSpace: MapCoordinateSpace.geo,
);
final str = info.toString();
expect(str, contains('SarMarkerInfo'));
expect(str, contains('Found Person')); // Display name, not enum name
expect(str, contains('colorIndex: 2'));
});
});
}

View File

@@ -0,0 +1,192 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
void main() {
group('VoiceEnvelope', () {
test('encodes and parses valid envelope', () {
final env = VoiceEnvelope(
sessionId: '0000000a',
mode: VoicePacketMode.mode1200,
total: 4,
durationMs: 3000,
);
final text = env.encodeText();
expect(VoiceEnvelope.isVoiceEnvelopeText(text), isTrue);
expect(text.startsWith('VE3:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = VoiceEnvelope.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.mode, equals(VoicePacketMode.mode1200));
expect(parsed.total, equals(4));
expect(parsed.durationMs, equals(3000));
expect(parsed.version, equals(3));
});
test('rejects invalid envelope payload', () {
final text = 'VE3:bad_sid:1:2:1000';
expect(VoiceEnvelope.tryParseText(text), isNull);
});
test('rejects legacy v1 envelope prefix', () {
const legacy = 'VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1';
expect(VoiceEnvelope.tryParseText(legacy), isNull);
});
});
group('VoiceFetchRequest', () {
test('encodes and parses valid request', () {
final req = VoiceFetchRequest(
sessionId: '0000000a',
requesterKey6: 'ffeeddccbbaa',
);
final text = req.encodeText();
expect(VoiceFetchRequest.isVoiceFetchRequestText(text), isTrue);
expect(text.startsWith('VR3:'), isTrue);
expect(text.split(':')[1], equals('a'));
final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('0000000a'));
expect(parsed.want, equals('all'));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(3));
});
test('rejects invalid request payload', () {
expect(
VoiceFetchRequest.tryParseText(
'VR3:a:chunk:ffeeddccbbaa',
),
isNull,
);
});
test('rejects legacy v1 request prefix', () {
const legacy = 'VR1:00112233:a:ffeeddccbbaa:1700000001:1';
expect(VoiceFetchRequest.tryParseText(legacy), isNull);
});
test('encodes and parses missing-packet request', () {
final req = VoiceFetchRequest(
sessionId: '0000000a',
want: 'missing',
missingIndices: const [0, 1, 2, 3, 7],
requesterKey6: 'ffeeddccbbaa',
);
final text = req.encodeText();
expect(text, contains(':m0-3.7:'));
final parsed = VoiceFetchRequest.tryParseText(text);
expect(parsed, isNotNull);
expect(parsed!.want, equals('missing'));
expect(parsed.missingIndices, equals([0, 1, 2, 3, 7]));
});
test('encodes and parses binary fetch request', () {
final req = VoiceFetchRequest(
sessionId: '01020304',
want: 'missing',
missingIndices: const [1, 4],
requesterKey6: 'ffeeddccbbaa',
);
final payload = req.encodeBinary();
expect(VoiceFetchRequest.isVoiceFetchRequestBinary(payload), isTrue);
final parsed = VoiceFetchRequest.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.want, equals('missing'));
expect(parsed.missingIndices, equals([1, 4]));
expect(parsed.requesterKey6, equals('ffeeddccbbaa'));
expect(parsed.version, equals(3));
});
});
group('VoiceFragmentAck', () {
test('encodes and parses binary ack', () {
final ack = VoiceFragmentAck(sessionId: '01020304', index: 7);
final payload = ack.encodeBinary();
expect(VoiceFragmentAck.isVoiceFragmentAckBinary(payload), isTrue);
final parsed = VoiceFragmentAck.tryParseBinary(payload);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.index, equals(7));
});
});
group('VoicePacket binary format', () {
test('constructs binary datagram from actual packet data', () {
final actualCodec2 = Uint8List.fromList([
0xD3,
0x19,
0x7A,
0x00,
0xFE,
0x44,
0xC1,
0x2B,
0x88,
]);
final pkt = VoicePacket(
sessionId: '01020304',
mode: VoicePacketMode.mode1300,
index: 2,
total: 5,
codec2Data: actualCodec2,
);
final datagram = pkt.encodeBinary();
expect(datagram[0], equals(0x56)); // magic 'V'
expect(datagram.sublist(1, 5), equals(Uint8List.fromList([1, 2, 3, 4])));
expect(datagram[5], equals(2));
expect(datagram.sublist(6), equals(actualCodec2));
final parsed = VoicePacket.tryParseBinary(datagram);
expect(parsed, isNotNull);
expect(parsed!.sessionId, equals('01020304'));
expect(parsed.mode, equals(VoicePacketMode.mode1300));
expect(parsed.index, equals(2));
expect(parsed.total, equals(0));
expect(parsed.codec2Data, equals(actualCodec2));
});
});
group('VoiceWaveform', () {
test('builds bars from packet bytes', () {
final packet = VoicePacket(
sessionId: '1234abcd',
mode: VoicePacketMode.mode1200,
index: 0,
total: 1,
codec2Data: Uint8List.fromList([
0,
255,
10,
245,
120,
130,
64,
192,
32,
224,
]),
);
final bars = VoiceWaveform.buildBarsFromPackets([packet], bars: 8);
expect(bars.length, equals(8));
expect(bars.every((v) => v >= 0.0 && v <= 1.0), isTrue);
expect(bars.any((v) => v > 0.2), isTrue);
});
test('returns zeros for missing packet data', () {
final bars = VoiceWaveform.buildBarsFromPackets(const [], bars: 6);
expect(bars, equals(List<double>.filled(6, 0.0)));
});
});
}

33
test/widget_test.dart Normal file
View File

@@ -0,0 +1,33 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/main.dart';
import 'package:meshcore_sar_app/screens/home_screen.dart';
import 'package:meshcore_sar_app/screens/welcome_wizard_screen.dart';
void main() {
testWidgets('App launches smoke test', (WidgetTester tester) async {
// Build app.
await tester.pumpWidget(const MeshCoreSarApp());
// During async initialization, loading indicator is shown.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Give async startup a bounded amount of time without requiring full settle
// (the app may keep background animations/timers alive).
await tester.pump(const Duration(seconds: 2));
expect(
find.byType(CircularProgressIndicator).evaluate().isNotEmpty ||
find.byType(HomeScreen).evaluate().isNotEmpty ||
find.byType(WelcomeWizardScreen).evaluate().isNotEmpty,
isTrue,
);
});
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart';
void main() {
Future<void> pumpSheet(
WidgetTester tester, {
required Future<void> Function(String name, String secret) onCreateChannel,
}) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: AddChannelSheet(onCreateChannel: onCreateChannel),
),
),
),
),
);
}
testWidgets('submits hash channels with an empty secret', (tester) async {
String? submittedName;
String? submittedSecret;
await pumpSheet(
tester,
onCreateChannel: (name, secret) async {
submittedName = name;
submittedSecret = secret;
},
);
await tester.enterText(find.byType(TextFormField).first, '#slovenia');
await tester.pump();
await tester.tap(find.text('Create Channel'));
await tester.pumpAndSettle();
expect(submittedName, '#slovenia');
expect(submittedSecret, '');
});
testWidgets('uses done action for hash channels', (tester) async {
String? submittedName;
await pumpSheet(
tester,
onCreateChannel: (name, secret) async {
submittedName = name;
},
);
await tester.enterText(find.byType(TextFormField).first, '#slovenia');
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(submittedName, '#slovenia');
});
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/widgets/common/bidirectional_refresh.dart';
void main() {
Future<BuildContext> pumpRefreshHarness(
WidgetTester tester, {
required Future<void> Function() onRefresh,
}) {
late BuildContext childContext;
return tester
.pumpWidget(
MaterialApp(
home: Scaffold(
body: BidirectionalRefresh(
onRefresh: onRefresh,
child: Builder(
builder: (context) {
childContext = context;
return const SizedBox.expand();
},
),
),
),
),
)
.then((_) => childContext);
}
OverscrollNotification topOverscroll(BuildContext context) {
return OverscrollNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: 500,
pixels: 0,
viewportDimension: 300,
axisDirection: AxisDirection.down,
devicePixelRatio: 1,
),
context: context,
overscroll: -120,
);
}
OverscrollNotification bottomOverscroll(BuildContext context) {
return OverscrollNotification(
metrics: FixedScrollMetrics(
minScrollExtent: 0,
maxScrollExtent: 500,
pixels: 500,
viewportDimension: 300,
axisDirection: AxisDirection.down,
devicePixelRatio: 1,
),
context: context,
overscroll: 120,
);
}
testWidgets('refresh triggers from top overscroll', (tester) async {
var refreshCount = 0;
final childContext = await pumpRefreshHarness(
tester,
onRefresh: () async {
refreshCount++;
},
);
topOverscroll(childContext).dispatch(childContext);
await tester.pump();
expect(refreshCount, 1);
});
testWidgets('refresh triggers from bottom overscroll', (tester) async {
var refreshCount = 0;
final childContext = await pumpRefreshHarness(
tester,
onRefresh: () async {
refreshCount++;
},
);
bottomOverscroll(childContext).dispatch(childContext);
await tester.pump();
expect(refreshCount, 1);
});
}

View File

@@ -0,0 +1,36 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/widgets/map/compass/compass_math.dart';
void main() {
group('compass dial math', () {
test('rotates the rose opposite the heading', () {
expect(compassRoseRotationRadians(0), closeTo(0, 1e-10));
expect(compassRoseRotationRadians(90), closeTo(-pi / 2, 1e-10));
expect(compassRoseRotationRadians(450), closeTo(-pi / 2, 1e-10));
});
test('places east at the top when heading east', () {
expect(
compassDialAngleRadians(markerDegrees: 90, headingDegrees: 90),
closeTo(-pi / 2, 1e-10),
);
expect(
compassDialAngleRadians(markerDegrees: 270, headingDegrees: 90),
closeTo(pi / 2, 1e-10),
);
});
test('places west at the top when heading west', () {
expect(
compassDialAngleRadians(markerDegrees: 270, headingDegrees: 270),
closeTo(-pi / 2, 1e-10),
);
expect(
compassDialAngleRadians(markerDegrees: 90, headingDegrees: 270),
closeTo(pi / 2, 1e-10),
);
});
});
}

View File

@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/services/network_scanner_service.dart';
import 'package:meshcore_sar_app/widgets/connection_dialog.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeConnectionProvider extends ConnectionProvider {
int startScanCalls = 0;
int stopScanCalls = 0;
bool _isScanning = false;
@override
bool get isScanning => _isScanning;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<void> startScan() async {
startScanCalls += 1;
_isScanning = true;
notifyListeners();
}
@override
Future<void> stopScan() async {
stopScanCalls += 1;
_isScanning = false;
notifyListeners();
}
}
class _ConnectableFakeConnectionProvider extends ConnectionProvider {
int connectCalls = 0;
@override
List<ScannedDevice> get scannedDevices => [
ScannedDevice(device: BluetoothDevice.fromId('test-device'), rssi: -55),
];
@override
String? get error => null;
@override
Future<bool> connect(BluetoothDevice device) async {
connectCalls += 1;
return true;
}
}
class _TcpConnectableFakeConnectionProvider extends ConnectionProvider {
int connectTcpCalls = 0;
String? connectedHost;
int? connectedPort;
@override
List<ScannedDevice> get scannedDevices => const [];
@override
String? get error => null;
@override
Future<bool> connectTcp(String host, int port) async {
connectTcpCalls += 1;
connectedHost = host;
connectedPort = port;
return true;
}
}
class _FakeNetworkScannerService extends NetworkScannerService {
_FakeNetworkScannerService({
this.keepScanning = false,
bool initiallyScanning = false,
}) : _isScanning = initiallyScanning;
final bool keepScanning;
int scanCalls = 0;
int stopScanCalls = 0;
bool _isScanning;
@override
bool get isScanning => _isScanning;
@override
bool get hasCachedResults => false;
@override
List<DiscoveredServer> get cachedServers => const [];
@override
Future<List<DiscoveredServer>> scan({int? port}) async {
scanCalls += 1;
_isScanning = keepScanning;
if (keepScanning) {
onProgressUpdate?.call(1, 10);
}
return const [];
}
@override
void clearCache() {}
@override
void stopScan() {
stopScanCalls += 1;
_isScanning = false;
}
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('BLE scan waits for explicit user action', (tester) async {
final connectionProvider = _FakeConnectionProvider();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ConnectionDialog()),
),
),
);
await tester.pump();
expect(connectionProvider.startScanCalls, 0);
expect(
find.text('Press scan to search for nearby devices'),
findsOneWidget,
);
expect(find.text('Scan'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Scan'));
await tester.pump();
expect(connectionProvider.stopScanCalls, 1);
expect(connectionProvider.startScanCalls, 1);
});
testWidgets('successful BLE connect closes the dialog immediately', (
tester,
) async {
final connectionProvider = _ConnectableFakeConnectionProvider();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => const ConnectionDialog(),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(ConnectionDialog), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectCalls, 1);
expect(find.byType(ConnectionDialog), findsNothing);
});
testWidgets('manual TCP connect can cancel discovery and use a custom port', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final connectionProvider = _TcpConnectableFakeConnectionProvider();
final networkScanner = _FakeNetworkScannerService(
initiallyScanning: true,
keepScanning: true,
);
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => ConnectionDialog(
networkScanner: networkScanner,
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Network'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final manualEntryButton = find.widgetWithText(
TextButton,
'Cancel and enter manually',
);
final onPressed = tester.widget<TextButton>(manualEntryButton).onPressed;
expect(onPressed, isNotNull);
onPressed!();
await tester.pumpAndSettle();
expect(networkScanner.stopScanCalls, greaterThanOrEqualTo(1));
await tester.enterText(find.widgetWithText(TextField, 'IP address'), '192.168.1.42');
await tester.enterText(find.widgetWithText(TextField, 'TCP port'), '6001');
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.pumpAndSettle();
expect(connectionProvider.connectTcpCalls, 1);
expect(connectionProvider.connectedHost, '192.168.1.42');
expect(connectionProvider.connectedPort, 6001);
expect(find.byType(ConnectionDialog), findsNothing);
});
testWidgets('recent servers show saved metadata and can reconnect', (
tester,
) async {
SharedPreferences.setMockInitialValues({
'recent_tcp_connections_v1': [
'{"name":"Mesh Node Alpha","host":"10.0.0.5","port":5001,"lastUsedAt":"2026-04-18T12:00:00.000Z"}',
],
});
final connectionProvider = _TcpConnectableFakeConnectionProvider();
final networkScanner = _FakeNetworkScannerService();
await tester.pumpWidget(
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () {
showModalBottomSheet<Object?>(
context: context,
isScrollControlled: true,
builder: (_) => ConnectionDialog(
networkScanner: networkScanner,
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Network'));
await tester.pumpAndSettle();
expect(find.text('Recently used'), findsOneWidget);
expect(find.text('Mesh Node Alpha'), findsOneWidget);
expect(find.text('10.0.0.5:5001'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Connect').first);
await tester.pumpAndSettle();
expect(connectionProvider.connectTcpCalls, 1);
expect(connectionProvider.connectedHost, '10.0.0.5');
expect(connectionProvider.connectedPort, 5001);
});
}

View File

@@ -0,0 +1,123 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
void main() {
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 0,
}) {
final publicKey = Uint8List(32);
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(child: ContactAvatar(contact: contact)),
),
),
);
}
testWidgets('renders label avatar for rooms', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'Operations Room', type: ContactType.room),
);
expect(find.text('OR'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.meeting_room), findsNothing);
});
testWidgets('renders hash label avatar for rooms', (tester) async {
await pumpAvatar(
tester,
buildContact(name: '#ops-room', type: ContactType.room),
);
expect(find.text('#OP'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.meeting_room), findsNothing);
});
testWidgets('renders compact hash label avatar when name includes spacing', (
tester,
) async {
await pumpAvatar(
tester,
buildContact(name: '# foo alpha', type: ContactType.room),
);
expect(find.text('#FO'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.meeting_room), findsNothing);
});
testWidgets('renders label avatar for channels', (tester) async {
await pumpAvatar(
tester,
buildContact(name: '#ops', type: ContactType.channel, secondByte: 3),
);
expect(find.text('#OP'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.public), findsNothing);
});
testWidgets('renders non-hash label avatar for channels', (tester) async {
await pumpAvatar(
tester,
buildContact(
name: 'Command Net',
type: ContactType.channel,
secondByte: 3,
),
);
expect(find.text('CN'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing);
expect(find.byIcon(Icons.public), findsNothing);
});
testWidgets('renders round avatar for chat contacts', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'John Smith', type: ContactType.chat),
);
expect(find.text('JS'), findsOneWidget);
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.byIcon(Icons.person), findsNothing);
});
testWidgets('renders sensor icon avatar for sensor contacts', (tester) async {
await pumpAvatar(
tester,
buildContact(name: 'WX Station', type: ContactType.sensor),
);
expect(find.byType(CircleAvatar), findsOneWidget);
expect(find.byIcon(Icons.sensors), findsOneWidget);
expect(find.text('WS'), findsNothing);
});
}

View File

@@ -0,0 +1,263 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/app_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/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeAppProvider extends ChangeNotifier implements AppProvider {
@override
ChannelLocationSharingMode? channelLocationSharingModeForChannel(
int channelIdx,
) {
return null;
}
@override
Future<ChannelLocationSharingState> getChannelLocationSharingState(
int channelIdx,
) async {
return const ChannelLocationSharingState(
mode: ChannelLocationSharingMode.appFallback,
isSharing: false,
hardwareSupported: false,
isConnected: false,
);
}
@override
dynamic noSuchMethod(Invocation invocation) {
return null;
}
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46562000,
advLon: 14950000,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<Future<void> Function()> pumpTile(
WidgetTester tester,
Contact contact, {
SensorsProvider? sensorsProvider,
}) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final appProvider = _FakeAppProvider();
final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider();
final ownsSensorsProvider = sensorsProvider == null;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<ConnectionProvider>.value(
value: connectionProvider,
),
ChangeNotifierProvider<ContactsProvider>.value(value: contactsProvider),
ChangeNotifierProvider<MessagesProvider>.value(value: messagesProvider),
ChangeNotifierProvider<SensorsProvider>.value(
value: resolvedSensorsProvider,
),
ChangeNotifierProvider<MapProvider>.value(value: mapProvider),
ChangeNotifierProvider<AppProvider>.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: ContactTile(contact: contact)),
),
),
);
return () async {
await tester.pumpWidget(const SizedBox.shrink());
connectionProvider.dispose();
if (ownsSensorsProvider) {
resolvedSensorsProvider.dispose();
}
await tester.pump();
};
}
Future<void> withPumpedTile(
WidgetTester tester,
Contact contact,
Future<void> Function() body, {
SensorsProvider? sensorsProvider,
}) async {
final dispose = await pumpTile(
tester,
contact,
sensorsProvider: sensorsProvider,
);
try {
await body();
} finally {
await dispose();
}
}
testWidgets('does not show diagnostic action for non-channel contacts', (
tester,
) async {
await withPumpedTile(
tester,
buildContact(name: 'John Smith', type: ContactType.chat),
() async {
await tester.tap(find.text('John Smith'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsNothing);
},
);
});
testWidgets('does not show diagnostic action for channels', (tester) async {
await withPumpedTile(
tester,
buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3),
() async {
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Trace'), findsNothing);
},
);
});
testWidgets('shows overridden contact name as primary label', (tester) async {
await withPumpedTile(
tester,
buildContact(
name: 'John Smith',
type: ContactType.chat,
).copyWith(nameOverride: 'Rescue One'),
() async {
expect(find.text('Rescue One'), findsOneWidget);
expect(find.text('John Smith'), findsNothing);
},
);
});
testWidgets('hides public key in contact tile', (tester) async {
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
await withPumpedTile(tester, contact, () async {
expect(find.text(contact.publicKeyShort), findsNothing);
expect(find.byIcon(Icons.key_outlined), findsNothing);
});
});
testWidgets('sensor contacts can be added to sensors', (tester) async {
await withPumpedTile(
tester,
buildContact(name: 'WX Station', type: ContactType.sensor),
() async {
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Add to Sensors'), findsOneWidget);
},
);
});
testWidgets('sensor preview shows telemetry card', (tester) async {
final contact = buildContact(name: 'WX Station', type: ContactType.sensor)
.copyWith(
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
humidity: 58.0,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'__source_channel:humidity': 1,
'co2': 415.0,
'illuminance_2': 500.0,
'current_2': 0.015,
'power_2': 0.25,
'distance_2': 1.234,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
await withPumpedTile(tester, contact, () async {
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(find.text('Preview'), findsOneWidget);
await tester.tap(find.text('Preview'));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget);
expect(find.text('21.5°C'), findsOneWidget);
expect(find.text('CO2'), findsOneWidget);
expect(find.text('415 ppm'), findsOneWidget);
expect(find.text('Illuminance'), findsOneWidget);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(find.text('Current'), findsOneWidget);
expect(find.text('15 mA'), findsOneWidget);
expect(find.text('Power'), findsOneWidget);
expect(find.text('Distance'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_metric_battery')),
findsOneWidget,
);
expect(find.text('ch1'), findsWidgets);
expect(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget,
);
final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard));
final batteryTileSize = tester.getSize(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(SensorTelemetryCard), findsNothing);
});
});
}

View File

@@ -0,0 +1,677 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:geolocator/geolocator.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/models/path_selection.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:meshcore_sar_app/utils/message_extensions.dart';
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() {
SharedPreferences.setMockInitialValues({});
LocationTrackingService().currentPosition = null;
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
});
testWidgets('received bubbles open details on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'received-signal',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(1),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Inbound message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
lastEchoRssiDbm: -84,
lastEchoSnrRaw: 24,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('1 hop'), findsNothing);
expect(find.text('Fair'), findsNothing);
expect(find.text('-84'), findsNothing);
expect(find.text('6.0'), findsNothing);
await tester.tap(find.text('Inbound message'));
await tester.pump(kDoubleTapTimeout);
expect(find.text('1 hop'), findsNothing);
expect(find.text('-84'), findsNothing);
await _doubleTap(tester, find.text('Inbound message'));
await tester.pumpAndSettle();
expect(find.text('Message details'), findsOneWidget);
expect(find.text('1 hop'), findsNothing);
expect(find.text('Fair'), findsNothing);
expect(find.text('-84'), findsNothing);
expect(find.text('6.0'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('delivered direct bubbles open details on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'sent-direct-signal',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(11),
recipientPublicKey: _key(77),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Outbound message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: 320,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
await tester.tap(find.text('Outbound message'));
await tester.pump(kDoubleTapTimeout);
expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
await _doubleTap(tester, find.text('Outbound message'));
await tester.pumpAndSettle();
expect(find.text('Message details'), findsOneWidget);
expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('sent channel bubbles open details on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'sent-channel-signal',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(21),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Broadcast message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
echoCount: 2,
lastEchoRssiDbm: -76,
lastEchoSnrRaw: 20,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('x2'), findsNothing);
expect(find.text('-76'), findsNothing);
expect(find.text('5.0'), findsNothing);
await tester.tap(find.text('Broadcast message'));
await tester.pump(kDoubleTapTimeout);
expect(find.text('x2'), findsNothing);
expect(find.text('-76'), findsNothing);
await _doubleTap(tester, find.text('Broadcast message'));
await tester.pumpAndSettle();
expect(find.text('Message details'), findsOneWidget);
expect(find.text('x2'), findsNothing);
expect(find.text('-76'), findsNothing);
expect(find.text('5.0'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('sent channel status shows heard count and flood route', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'sent-channel-status',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(22),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000001,
text: 'Flood status',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000001500),
deliveryStatus: MessageDeliveryStatus.sent,
echoCount: 2,
);
harness.messagesProvider.updateMessageRouteSelection(
'sent-channel-status',
PathSelection.flood(),
routerFallbackAttempted: false,
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: harness.messagesProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) =>
Text(message.getLocalizedDeliveryStatus(context)),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('2 nodes • Flood route'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('details show sent channel echo relay path', (tester) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'sent-channel-echo-details',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(23),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000002,
text: 'Echo detail path',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000002500),
deliveryStatus: MessageDeliveryStatus.sent,
echoCount: 1,
lastEchoRssiDbm: -88,
lastEchoSnrRaw: 6,
);
harness.messagesProvider.addSentMessage(message);
harness.messagesProvider.handleMessageEcho(
'sent-channel-echo-details',
1,
6,
-88,
pathBytes: Uint8List.fromList([0x10, 0x20, 0xAA]),
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Echo detail path'));
await tester.pumpAndSettle();
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(find.text('Last echo relay'), findsOneWidget);
expect(find.text('AA'), findsOneWidget);
expect(find.text('Last echo path'), findsOneWidget);
expect(find.text('10:20:aa'), findsWidgets);
expect(find.text('Last echo bytes report'), findsOneWidget);
expect(
find.text('3 bytes [10 20 AA] • hops #1=10, #2=20, #3=AA'),
findsOneWidget,
);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('channel bubbles refresh to synced channel names', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'channel-name-refresh',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(61),
channelIdx: 3,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Team update',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
expect(find.text('Channel 3'), findsOneWidget);
expect(find.text('#slovenija'), findsNothing);
harness.channelsProvider.addOrUpdateChannel(
index: 3,
name: '#slovenija',
secret: Uint8List(16),
);
await tester.pumpAndSettle();
expect(find.text('#slovenija'), findsOneWidget);
expect(find.text('Channel 3'), findsNothing);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble detects and opens links', (tester) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'message-link',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(31),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Check https://example.com/docs, then ping @[Rescue Team].',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere(
(widget) =>
widget.text.toPlainText().contains('https://example.com/docs'),
);
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == 'https://example.com/docs',
);
expect(richText.text.toPlainText(), contains('https://example.com/docs'));
expect(find.text('@Rescue Team'), findsOneWidget);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pump();
expect(launchedUrls, ['https://example.com/docs']);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble opens meshcore links in add contact screen', (
tester,
) async {
final harness = await _TestHarness.create();
try {
const payloadSegment = '00112233445566778899aabbccddeeff';
final advert =
'meshcore://'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'$payloadSegment'
'0011';
final message = Message(
id: 'message-meshcore-link',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(41),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Import $advert',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == advert,
);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pumpAndSettle();
expect(find.text('Import a shared contact advert'), findsOneWidget);
expect(find.text(advert), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('message bubble linkifies raw meshcore adverts', (tester) async {
final harness = await _TestHarness.create();
try {
const advert =
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'00112233445566778899aabbccddeeff'
'0011';
final message = Message(
id: 'message-meshcore-raw',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(51),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Import raw advert: $advert',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
final richText = tester
.widgetList<RichText>(find.byType(RichText))
.firstWhere((widget) => widget.text.toPlainText().contains(advert));
final linkSpan = _findTextSpan(
richText.text,
(span) => span.text == advert,
);
expect(linkSpan, isNotNull);
final recognizer = linkSpan!.recognizer;
expect(recognizer, isA<TapGestureRecognizer>());
(recognizer! as TapGestureRecognizer).onTap!();
await tester.pumpAndSettle();
expect(find.text('Import a shared contact advert'), findsOneWidget);
expect(find.text(advert), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('technical details show a location map for stored snapshots', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'message-location-details',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(71),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Location details',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
);
harness.messagesProvider.addMessage(
message,
contactLocationSnapshot: MessageContactLocation(
location: const LatLng(46.0569, 14.5058),
source: 'advert',
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000600),
sourceTimestamp: DateTime.fromMillisecondsSinceEpoch(1700000000400),
),
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Location details'));
await tester.pumpAndSettle();
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
expect(find.text('46.056900, 14.505800'), findsOneWidget);
expect(find.text('shared advert'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets(
'technical details show a location map for sent channel messages using live position fallback',
(tester) async {
final harness = await _TestHarness.create();
try {
LocationTrackingService().currentPosition = Position(
latitude: 46.0569,
longitude: 14.5058,
timestamp: DateTime.fromMillisecondsSinceEpoch(1700000003000),
accuracy: 6.0,
altitude: 300.0,
altitudeAccuracy: 6.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
final message = Message(
id: 'channel-live-fallback',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(81),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Channel fallback',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pumpAndSettle();
await tester.longPress(find.text('Channel fallback'));
await tester.pumpAndSettle();
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(find.byType(flutter_map.FlutterMap), findsOneWidget);
expect(find.text('46.056900, 14.505800'), findsOneWidget);
expect(find.text('shared location'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
},
);
}
Widget _buildApp(_TestHarness harness, Message message) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: harness.connectionProvider),
ChangeNotifierProvider.value(value: harness.contactsProvider),
ChangeNotifierProvider.value(value: harness.messagesProvider),
ChangeNotifierProvider.value(value: harness.drawingProvider),
ChangeNotifierProvider.value(value: harness.channelsProvider),
ChangeNotifierProvider.value(value: harness.voiceProvider),
ChangeNotifierProvider.value(value: harness.imageProvider),
ChangeNotifierProvider.value(value: harness.appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: MessageBubble(message: message)),
),
);
}
class _TestHarness {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final drawingProvider = DrawingProvider();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
late final AppProvider appProvider;
static Future<_TestHarness> create() async {
final harness = _TestHarness();
await harness.messagesProvider.initialize();
await harness.drawingProvider.initialize();
harness.appProvider = AppProvider(
connectionProvider: harness.connectionProvider,
contactsProvider: harness.contactsProvider,
messagesProvider: harness.messagesProvider,
drawingProvider: harness.drawingProvider,
channelsProvider: harness.channelsProvider,
voiceProvider: harness.voiceProvider,
imageProvider: harness.imageProvider,
);
return harness;
}
bool _isDisposed = false;
void dispose() {
if (_isDisposed) {
return;
}
_isDisposed = true;
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
}
}
Uint8List _prefix(int seed) =>
Uint8List.fromList(List<int>.generate(6, (index) => seed + index));
Uint8List _key(int seed) =>
Uint8List.fromList(List<int>.generate(32, (index) => seed + index));
Future<void> _disposeHarness(WidgetTester tester, _TestHarness harness) async {
await tester.pump(kDoubleTapTimeout);
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
await tester.pump();
}
Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pump(kDoubleTapMinTime);
await tester.tap(finder);
await tester.pump();
}
TextSpan? _findTextSpan(
InlineSpan span,
bool Function(TextSpan span) predicate,
) {
if (span is! TextSpan) {
return null;
}
if (predicate(span)) {
return span;
}
for (final child in span.children ?? const <InlineSpan>[]) {
final match = _findTextSpan(child, predicate);
if (match != null) {
return match;
}
}
return null;
}

View File

@@ -0,0 +1,246 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/widgets/messages/recipient_selector_sheet.dart';
void main() {
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 0,
int flags = 0,
int lastAdvert = 0,
}) {
final publicKey = Uint8List(32);
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: flags,
outPathLen: 0,
outPath: Uint8List(0),
advName: name,
lastAdvert: lastAdvert,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
Future<void> pumpSheet(
WidgetTester tester, {
List<Contact>? contacts,
List<Contact>? channels,
MessagesProvider? messagesProvider,
bool showAllOption = true,
}) async {
final resolvedChannels =
channels ??
[buildContact(name: 'Ops', type: ContactType.channel, secondByte: 3)];
final resolvedContacts =
contacts ?? [buildContact(name: 'John Smith', type: ContactType.chat)];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecipientSelectorSheet(
contacts: resolvedContacts,
rooms: const [],
channels: resolvedChannels,
unreadCount: 11,
unreadCountsByPublicKey: {
for (final channel in resolvedChannels) channel.publicKeyHex: 7,
for (final contact in resolvedContacts) contact.publicKeyHex: 3,
},
currentDestinationType: 'all',
showAllOption: showAllOption,
messagesProvider: messagesProvider,
onSelect: (selectedContact, destinationType) {},
),
),
),
);
}
testWidgets('renders unread badges for all and destination filters', (
tester,
) async {
await pumpSheet(tester);
expect(find.byKey(const Key('unread-badge-11')), findsOneWidget);
expect(find.byKey(const Key('unread-badge-7')), findsOneWidget);
expect(find.byKey(const Key('unread-badge-3')), findsOneWidget);
expect(find.text('11'), findsOneWidget);
expect(find.text('7'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
});
testWidgets('can hide show all option for contact-only flows', (
tester,
) async {
final contact = buildContact(name: 'John Smith', type: ContactType.chat);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecipientSelectorSheet(
contacts: [contact],
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: const {},
showAllOption: false,
onSelect: (selectedRecipient, draftMessage) {},
),
),
),
);
expect(find.text('Show all'), findsNothing);
expect(find.text('John Smith'), findsOneWidget);
});
testWidgets('sorts contacts with favourites first, then last seen', (
tester,
) async {
final recentNonFavourite = buildContact(
name: 'Alpha',
type: ContactType.chat,
secondByte: 1,
lastAdvert: 300,
);
final olderFavourite = buildContact(
name: 'Bravo',
type: ContactType.chat,
secondByte: 2,
flags: 0x01,
lastAdvert: 100,
);
final newerFavourite = buildContact(
name: 'Charlie',
type: ContactType.chat,
secondByte: 3,
flags: 0x01,
lastAdvert: 200,
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: RecipientSelectorSheet(
contacts: [recentNonFavourite, olderFavourite, newerFavourite],
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: const {},
showAllOption: false,
onSelect: (selectedRecipient, draftMessage) {},
),
),
),
);
final charlieY = tester.getTopLeft(find.text('Charlie')).dy;
final bravoY = tester.getTopLeft(find.text('Bravo')).dy;
final alphaY = tester.getTopLeft(find.text('Alpha')).dy;
expect(charlieY, lessThan(bravoY));
expect(bravoY, lessThan(alphaY));
});
testWidgets('shows channel activity and participants instead of raw ids', (
tester,
) async {
final channel = buildContact(
name: 'Ops',
type: ContactType.channel,
secondByte: 3,
);
final messagesProvider = MessagesProvider()
..addMessage(
Message(
id: 'channel-activity',
messageType: MessageType.channel,
senderName: 'Radio Alpha',
channelIdx: 3,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp:
DateTime.now()
.subtract(const Duration(minutes: 5))
.millisecondsSinceEpoch ~/
1000,
text: 'status update',
receivedAt: DateTime.now().subtract(const Duration(minutes: 5)),
),
);
await pumpSheet(
tester,
contacts: const [],
channels: [channel],
messagesProvider: messagesProvider,
showAllOption: false,
);
expect(
find.byKey(Key('channel-participants-${channel.publicKeyHex}')),
findsOneWidget,
);
expect(find.text('Radio Alpha'), findsNothing);
expect(find.text('5m ago'), findsOneWidget);
expect(find.textContaining('Channel 3'), findsNothing);
expect(find.text(channel.publicKeyShort.toUpperCase()), findsNothing);
});
testWidgets('shows contact activity instead of the public key', (
tester,
) async {
final contact = buildContact(
name: 'John Smith',
type: ContactType.chat,
secondByte: 4,
);
final messagesProvider = MessagesProvider()
..addMessage(
Message(
id: 'contact-activity',
messageType: MessageType.contact,
recipientPublicKey: contact.publicKey,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp:
DateTime.now()
.subtract(const Duration(hours: 2))
.millisecondsSinceEpoch ~/
1000,
text: 'check-in',
receivedAt: DateTime.now().subtract(const Duration(hours: 2)),
),
);
await pumpSheet(
tester,
contacts: [contact],
channels: const [],
messagesProvider: messagesProvider,
showAllOption: false,
);
expect(find.text('John Smith'), findsOneWidget);
expect(find.text('2h ago'), findsOneWidget);
expect(find.text(contact.publicKeyShort), findsNothing);
});
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
void main() {
testWidgets('renders expanded auto refresh intervals and selects value', (
tester,
) async {
var selected = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SensorAutoRefreshOptions(
selectedMinutes: selected,
onSelected: (minutes) {
selected = minutes;
},
),
),
),
);
expect(find.text('Off'), findsOneWidget);
expect(find.text('5m'), findsOneWidget);
expect(find.text('15m'), findsOneWidget);
expect(find.text('30m'), findsOneWidget);
expect(find.text('60m'), findsOneWidget);
expect(find.text('6h'), findsOneWidget);
expect(find.text('12h'), findsOneWidget);
expect(find.text('24h'), findsOneWidget);
await tester.tap(find.text('24h'));
await tester.pump();
expect(selected, 1440);
});
}

View File

@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/sensors_provider.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_history_sheet.dart';
void main() {
test('history screen honors initial field key when available', () {
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'extra:illuminance_2',
availableFieldKeys: const <String>[
'temperature',
'extra:illuminance_2',
],
);
expect(selectedFieldKey, 'extra:illuminance_2');
});
test('history screen falls back to first available field', () {
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'extra:missing',
availableFieldKeys: const <String>[
'temperature',
'extra:illuminance_2',
],
);
expect(selectedFieldKey, 'temperature');
});
test('history screen returns null when no fields are available', () {
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: 'temperature',
availableFieldKeys: const <String>[],
);
expect(selectedFieldKey, isNull);
});
test('history range filters to the latest 24 hours', () {
final samples = <SensorHistorySample>[
SensorHistorySample(
timestamp: DateTime(2026, 4, 1, 8),
values: const {'temperature': 10},
),
SensorHistorySample(
timestamp: DateTime(2026, 4, 2, 7),
values: const {'temperature': 11},
),
SensorHistorySample(
timestamp: DateTime(2026, 4, 2, 8),
values: const {'temperature': 12},
),
];
final filtered = filterSensorHistorySamples(
samples: samples,
range: SensorHistoryRange.day,
);
expect(filtered.map((sample) => sample.values['temperature']), [10, 11, 12]);
});
test('history range all keeps every sample', () {
final samples = <SensorHistorySample>[
SensorHistorySample(
timestamp: DateTime(2026, 4, 1, 8),
values: const {'temperature': 10},
),
SensorHistorySample(
timestamp: DateTime(2026, 4, 2, 8),
values: const {'temperature': 12},
),
];
final filtered = filterSensorHistorySamples(
samples: samples,
range: SensorHistoryRange.all,
);
expect(filtered, hasLength(2));
});
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/screens/sensors_tab.dart';
import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart';
void main() {
testWidgets('renders selector previews and channel badges', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorMetricSelectorItem(
option: const SensorMetricOption(
key: 'extra:illuminance_2',
label: 'Illuminance (ch 2)',
defaultLabel: 'Illuminance',
channel: 2,
valuePreview: '~4.2 W/m2',
previewCardData: SensorMetricCardData(
fieldKey: 'extra:illuminance_2',
icon: Icons.light_mode_outlined,
label: 'Illuminance',
value: '~4.2 W/m2',
accent: Color(0xFFC17B1D),
channel: 2,
),
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('Show on sensor card'), findsOneWidget);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget,
);
expect(find.text('Channel 2'), findsOneWidget);
});
testWidgets('omits duplicate channel chip for channel 1', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorMetricSelectorItem(
option: const SensorMetricOption(
key: 'battery',
label: 'Battery',
defaultLabel: 'Battery',
channel: 1,
valuePreview: '84%',
previewCardData: SensorMetricCardData(
fieldKey: 'battery',
icon: Icons.battery_5_bar,
label: 'Battery',
value: '84%',
accent: Color(0xFF4B8E2F),
channel: 1,
),
),
visible: true,
span: 1,
canMoveUp: true,
canMoveDown: true,
onToggle: (_) {},
onRename: () {},
onMoveUp: () {},
onMoveDown: () {},
onSpanChanged: (_) {},
),
),
),
);
expect(find.text('Rename'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_battery')),
findsNothing,
);
expect(find.text('Channel 1'), findsNothing);
});
}

View File

@@ -0,0 +1,622 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/contact.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/widgets/sensors/sensor_telemetry_card.dart';
void main() {
Contact buildContact() {
final publicKey = Uint8List(32);
publicKey[0] = 0x44;
return Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
temperature: 21.5,
extraSensorData: const {
'__source_channel:temperature': 1,
'illuminance_2': 500.0,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
}
testWidgets('renders custom labels and channel badges', (tester) async {
final contact = buildContact();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature', 'extra:illuminance_2'},
labelOverrides: const {
'temperature': 'Ambient',
'extra:illuminance_2': 'Light',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'temperature',
'extra:illuminance_2',
}),
),
),
),
);
expect(find.text('Ambient'), findsOneWidget);
expect(find.text('Light'), findsOneWidget);
expect(find.text('Temperature'), findsNothing);
expect(find.text('Illuminance'), findsNothing);
expect(find.text('~4.2 W/m2'), findsOneWidget);
expect(find.textContaining('lx'), findsNothing);
expect(
find.byKey(const ValueKey('sensor_metric_temperature')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
findsOneWidget,
);
});
testWidgets('renders metrics in the provided order', (tester) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x45;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
batteryPercentage: 84,
temperature: 21.5,
extraSensorData: const {
'__source_channel:battery': 1,
'__source_channel:temperature': 1,
'illuminance_2': 500.0,
},
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'battery',
'temperature',
'extra:illuminance_2',
},
fieldOrder: const ['extra:illuminance_2', 'temperature', 'battery'],
fieldSpans: sensorFullWidthFieldSpans(const {
'battery',
'temperature',
'extra:illuminance_2',
}),
),
),
),
);
final illuminanceTop = tester.getTopLeft(
find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')),
);
final temperatureTop = tester.getTopLeft(
find.byKey(const ValueKey('sensor_metric_temperature')),
);
final batteryTop = tester.getTopLeft(
find.byKey(const ValueKey('sensor_metric_battery')),
);
expect(illuminanceTop.dy, lessThan(temperatureTop.dy));
expect(temperatureTop.dy, lessThan(batteryTop.dy));
});
testWidgets('renders MeshCore custom weather metrics', (tester) async {
tester.platformDispatcher.localeTestValue = const Locale('sl', 'SI');
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
final publicKey = Uint8List(32);
publicKey[0] = 0x46;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'extra:gust_2',
'extra:dew_2',
'extra:rain_2',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'extra:gust_2',
'extra:dew_2',
'extra:rain_2',
}),
),
),
),
);
expect(find.text('Wind gust'), findsOneWidget);
expect(find.text('Dew point'), findsOneWidget);
expect(find.text('Rain'), findsOneWidget);
expect(find.textContaining('km/h'), findsOneWidget);
expect(find.textContaining('m/s'), findsNothing);
expect(find.text('2°C'), findsOneWidget);
expect(find.text('12.3 mm'), findsOneWidget);
});
testWidgets('renders speed in mph for imperial system locale', (
tester,
) async {
tester.platformDispatcher.localeTestValue = const Locale('en', 'US');
addTearDown(tester.platformDispatcher.clearLocaleTestValue);
final publicKey = Uint8List(32);
publicKey[0] = 0x4A;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'speed_2': 3.7},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'extra:speed_2'},
fieldSpans: sensorFullWidthFieldSpans(const {'extra:speed_2'}),
),
),
),
);
expect(find.textContaining('mph'), findsOneWidget);
expect(find.textContaining('m/s'), findsNothing);
});
testWidgets('gps preview map recenters when telemetry location changes', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x4B;
Contact buildGpsContact(double latitude, double longitude) => Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'GPS Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
gpsLocation: LatLng(latitude, longitude),
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
),
);
var contact = buildGpsContact(46.0569, 14.5058);
await tester.pumpWidget(
StatefulBuilder(
builder: (context, setState) {
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Column(
children: [
ElevatedButton(
onPressed: () {
setState(() {
contact = buildGpsContact(46.1000, 14.6000);
});
},
child: const Text('Update'),
),
SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'gps'},
fieldSpans: sensorFullWidthFieldSpans(const {'gps'}),
),
],
),
),
);
},
),
);
final initialMap = tester.widget<flutter_map.FlutterMap>(
find.byType(flutter_map.FlutterMap).first,
);
final initialCenter = initialMap.mapController!.camera.center;
expect(initialCenter.latitude, closeTo(46.0569, 0.0001));
expect(initialCenter.longitude, closeTo(14.5058, 0.0001));
await tester.tap(find.text('Update'));
await tester.pump();
final updatedMap = tester.widget<flutter_map.FlutterMap>(
find.byType(flutter_map.FlutterMap).first,
);
final updatedCenter = updatedMap.mapController!.camera.center;
expect(updatedCenter.latitude, closeTo(46.1000, 0.0001));
expect(updatedCenter.longitude, closeTo(14.6000, 0.0001));
});
testWidgets('renders generic percentage separately from UV for weather payload', (tester) async {
tester.view.physicalSize = const Size(1600, 2600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
final publicKey = Uint8List(32);
publicKey[0] = 0x49;
final payload = Uint8List.fromList([
0x01, 0x74, 0x00, 0x00,
0x02, 0x78, 0x64,
0x02, 0x73, 0x24, 0x31,
0x02, 0x65, 0x67, 0xDE,
0x02, 0x8A, 0xFF, 0xE6,
0x02, 0x74, 0x01, 0xE0,
0x02, 0x9D, 0x00,
0x02, 0x68, 0x76,
0x02, 0x81, 0x00, 0xDC,
0x02, 0x89, 0x01, 0x36,
0x02, 0x67, 0x00, 0x2F,
0x02, 0xAD, 0x0A,
0x02, 0x84, 0x00, 0x50,
0x02, 0x8B, 0x00, 0xB3,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]);
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: CayenneLppParser.parse(payload),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {
'temperature',
'humidity',
'pressure',
'extra:percentage_2',
'extra:dew_2',
'extra:speed_2',
'extra:gust_2',
'extra:uv_2',
'extra:direction_2',
'extra:rain_2',
},
fieldSpans: sensorFullWidthFieldSpans(const {
'temperature',
'humidity',
'pressure',
'extra:percentage_2',
'extra:dew_2',
'extra:speed_2',
'extra:gust_2',
'extra:uv_2',
'extra:direction_2',
'extra:rain_2',
}),
),
),
),
),
);
expect(find.byKey(const ValueKey('sensor_metric_extra:percentage_2')), findsOneWidget);
expect(find.byKey(const ValueKey('sensor_metric_extra:uv_2')), findsOneWidget);
expect(find.text('100%'), findsOneWidget);
expect(find.text('1%'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('80°'), findsOneWidget);
expect(find.text('8°'), findsNothing);
});
testWidgets('renders direction metric with compact compass layout', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x47;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'direction_2': 111.0},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'extra:direction_2'},
fieldSpans: sensorFullWidthFieldSpans(const {'extra:direction_2'}),
),
),
),
);
expect(find.text('Direction'), findsOneWidget);
expect(find.text('111°'), findsOneWidget);
expect(find.text('E'), findsOneWidget);
expect(find.byIcon(Icons.navigation_rounded), findsOneWidget);
});
testWidgets('long pressing a telemetry bubble triggers refresh', (
tester,
) async {
final contact = buildContact();
var refreshCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onRefresh: () async {
refreshCount += 1;
},
),
),
),
);
await tester.longPress(
find.byKey(const ValueKey('sensor_metric_temperature')),
);
await tester.pumpAndSettle();
expect(refreshCount, 1);
});
testWidgets('overflow menu exposes move actions', (tester) async {
final contact = buildContact();
var moveUpCount = 0;
var moveDownCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onMoveUp: () async {
moveUpCount += 1;
},
onMoveDown: () async {
moveDownCount += 1;
},
),
),
),
);
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
expect(find.text('Move up'), findsOneWidget);
expect(find.text('Move down'), findsOneWidget);
await tester.tap(find.text('Move down'));
await tester.pumpAndSettle();
expect(moveUpCount, 0);
expect(moveDownCount, 1);
});
testWidgets('tapping a measurement tile triggers metric callback', (
tester,
) async {
final contact = buildContact();
String? tappedFieldKey;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onMetricTap: (fieldKey) async {
tappedFieldKey = fieldKey;
},
),
),
),
);
await tester.tap(find.byKey(const ValueKey('sensor_metric_temperature')));
await tester.pumpAndSettle();
expect(tappedFieldKey, 'temperature');
});
testWidgets('tapping the card body does not trigger metric callback', (
tester,
) async {
final contact = buildContact();
var tapCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
onMetricTap: (_) async {
tapCount += 1;
},
),
),
),
);
await tester.tap(find.text('WX Station'));
await tester.pumpAndSettle();
expect(tapCount, 0);
});
testWidgets('raw response metadata alone does not show an overflow menu', (
tester,
) async {
final publicKey = Uint8List(32);
publicKey[0] = 0x48;
final contact = Contact(
publicKey: publicKey,
type: ContactType.sensor,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'WX Station',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: ContactTelemetry(
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 1)),
extraSensorData: const {'__raw_lpp_hex': '01 67 00 d7'},
),
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SensorTelemetryCard(
contact: contact,
state: SensorRefreshState.idle,
visibleFields: const {'temperature'},
fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}),
),
),
),
);
expect(find.byIcon(Icons.more_vert), findsNothing);
expect(find.byIcon(Icons.chevron_right_rounded), findsNothing);
});
}

View File

@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart';
import 'package:meshcore_sar_app/widgets/settings/traffic_stats_reporting_section.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final launchedUrls = <String>[];
setUp(() {
launchedUrls.clear();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(call) async {
switch (call.method) {
case 'canLaunch':
return true;
case 'launch':
final arguments = Map<dynamic, dynamic>.from(
call.arguments as Map<dynamic, dynamic>,
);
launchedUrls.add(arguments['url'] as String);
return true;
}
return null;
},
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
null,
);
});
testWidgets('starts enabled by default and can be disabled', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
final service = TrafficStatsReportingService(
client: MockClient((request) async => http.Response('{}', 200)),
now: () => DateTime.utc(2026, 4, 3, 10, 6),
appVersionProvider: () async => '2026.0402.1+44',
);
await service.initialize(
deviceKey6Provider: () => 'a1b2c3d4e5f6',
);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: ListenableBuilder(
listenable: service,
builder: (context, child) => TrafficStatsReportingSection(
service: service,
),
),
),
),
);
expect(find.text('Anonymous RX stats'), findsOneWidget);
expect(find.text('Upload packet totals every 5 min'), findsOneWidget);
expect(find.text('Reporting interval'), findsNothing);
expect(service.isEnabled, isTrue);
await tester.tap(find.widgetWithText(TextButton, 'View'));
await tester.pump();
expect(launchedUrls, ['https://mcstats.dz0ny.dev']);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(service.isEnabled, isFalse);
expect(service.intervalMinutes, 5);
service.dispose();
});
}