chore: Initial commit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
Janez Troha
2026-09-04 16:53:52 +02:00
commit 7924803351
516 changed files with 207265 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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