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:
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