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