mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
feat: Add TCP connection support and UI integration
- Implemented TCP transport service for native platforms. - Added TCP connection screen with input fields for host and port. - Integrated TCP connection options into the scanner and USB screens. - Updated localization files for new TCP-related strings. - Added tests for TCP connection flow and error handling. - Enhanced USB screen to include TCP connection option. - Improved layout to ensure no overflow in narrow widths for scanner and USB screens.
This commit is contained in:
parent
a1b77bb29b
commit
7a2bb20bf7
43 changed files with 2391 additions and 123 deletions
170
test/screens/tcp_flow_test.dart
Normal file
170
test/screens/tcp_flow_test.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_open/screens/scanner_screen.dart';
|
||||
import 'package:meshcore_open/screens/tcp_screen.dart';
|
||||
|
||||
class _FakeMeshCoreConnector extends MeshCoreConnector {
|
||||
_FakeMeshCoreConnector();
|
||||
|
||||
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
|
||||
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
|
||||
String? initialEndpoint;
|
||||
int connectTcpCalls = 0;
|
||||
String? lastHost;
|
||||
int? lastPort;
|
||||
|
||||
@override
|
||||
MeshCoreConnectionState get state => initialState;
|
||||
|
||||
@override
|
||||
MeshCoreTransportType get activeTransport => initialTransport;
|
||||
|
||||
@override
|
||||
bool get isTcpTransportConnected =>
|
||||
initialState == MeshCoreConnectionState.connected &&
|
||||
initialTransport == MeshCoreTransportType.tcp;
|
||||
|
||||
@override
|
||||
String? get activeTcpEndpoint => initialEndpoint;
|
||||
|
||||
@override
|
||||
Future<void> connectTcp({required String host, required int port}) async {
|
||||
connectTcpCalls += 1;
|
||||
lastHost = host;
|
||||
lastPort = port;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTestApp({
|
||||
required MeshCoreConnector connector,
|
||||
required Widget child,
|
||||
Locale? locale,
|
||||
}) {
|
||||
return ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||
value: connector,
|
||||
child: MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final context = tester.element(find.byType(TcpScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
expect(find.text(l10n.tcpScreenTitle), findsOneWidget);
|
||||
expect(find.text(l10n.tcpHostLabel), findsOneWidget);
|
||||
expect(find.text(l10n.tcpPortLabel), findsOneWidget);
|
||||
expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TcpScreen validation errors are localized', (tester) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final context = tester.element(find.byType(TcpScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget);
|
||||
expect(connector.connectTcpCalls, 0);
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, '192.168.1.50');
|
||||
await tester.enterText(find.byType(TextField).at(1), '99999');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connector.connectTcpCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('TCP Bluetooth action returns to existing scanner route', (
|
||||
tester,
|
||||
) async {
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TcpScreen), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TcpScreen), findsNothing);
|
||||
expect(find.byType(ScannerScreen), findsOneWidget);
|
||||
final navigatorState = tester.state<NavigatorState>(find.byType(Navigator));
|
||||
expect(navigatorState.canPop(), isFalse);
|
||||
|
||||
// ScannerScreen.dispose() schedules disconnect work that debounces notify.
|
||||
// Drain that debounce timer before test teardown.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
testWidgets('TcpScreen narrow width long status text does not overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final connector = _FakeMeshCoreConnector()
|
||||
..initialState = MeshCoreConnectionState.connected
|
||||
..initialTransport = MeshCoreTransportType.tcp
|
||||
..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
connector: connector,
|
||||
child: const TcpScreen(),
|
||||
locale: const Locale('en'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final context = tester.element(find.byType(TcpScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
expect(
|
||||
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
}
|
||||
|
|
@ -116,12 +116,7 @@ void main() {
|
|||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(connector.connectUsbCalls, 0);
|
||||
|
|
@ -145,12 +140,7 @@ void main() {
|
|||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(connector.connectUsbCalls, 1);
|
||||
|
|
@ -179,6 +169,68 @@ void main() {
|
|||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
testWidgets('ScannerScreen narrow width keeps actions without overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final connector = _FakeMeshCoreConnector();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const ScannerScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final context = tester.element(find.byType(ScannerScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
expect(find.text(l10n.scanner_scan), findsOneWidget);
|
||||
|
||||
if (PlatformInfo.supportsUsbSerial) {
|
||||
expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget);
|
||||
}
|
||||
if (!PlatformInfo.isWeb) {
|
||||
expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
testWidgets('UsbScreen narrow width long status text does not overflow', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(320, 700));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
final connector =
|
||||
_FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected)
|
||||
..fakeUsbTransportConnected = true
|
||||
..fakeActiveUsbPortDisplayLabel =
|
||||
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(connector: connector, child: const UsbScreen()),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final context = tester.element(find.byType(UsbScreen));
|
||||
final l10n = AppLocalizations.of(context);
|
||||
expect(
|
||||
find.text(
|
||||
l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
});
|
||||
|
||||
group('Error Handling', () {
|
||||
testWidgets('shows error SnackBar when listing ports fails', (
|
||||
tester,
|
||||
|
|
@ -212,12 +264,7 @@ void main() {
|
|||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Connect'),
|
||||
matching: find.bySubtype<ElevatedButton>(),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byType(ListTile).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(connectAttempted, isTrue);
|
||||
|
|
|
|||
136
test/services/tcp_transport_service_native_test.dart
Normal file
136
test/services/tcp_transport_service_native_test.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/services/tcp_transport_service_native.dart';
|
||||
import 'package:meshcore_open/services/usb_serial_frame_codec.dart';
|
||||
|
||||
final class _DelayedConnectOverrides extends IOOverrides {
|
||||
_DelayedConnectOverrides(this.delay);
|
||||
|
||||
final Duration delay;
|
||||
|
||||
@override
|
||||
Future<Socket> socketConnect(
|
||||
host,
|
||||
int port, {
|
||||
sourceAddress,
|
||||
int sourcePort = 0,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
await Future<void>.delayed(delay);
|
||||
return super.socketConnect(
|
||||
host,
|
||||
port,
|
||||
sourceAddress: sourceAddress,
|
||||
sourcePort: sourcePort,
|
||||
timeout: timeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('connect/disconnect updates TCP transport state', () async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final service = TcpTransportService();
|
||||
|
||||
try {
|
||||
await service.connect(
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: server.port,
|
||||
);
|
||||
|
||||
expect(service.isConnected, isTrue);
|
||||
expect(
|
||||
service.activeEndpoint,
|
||||
'${InternetAddress.loopbackIPv4.address}:${server.port}',
|
||||
);
|
||||
|
||||
await service.disconnect();
|
||||
|
||||
expect(service.isConnected, isFalse);
|
||||
expect(service.activeEndpoint, isNull);
|
||||
} finally {
|
||||
await service.disconnect();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect is safe when already disconnected', () async {
|
||||
final service = TcpTransportService();
|
||||
|
||||
await service.disconnect();
|
||||
await service.disconnect();
|
||||
|
||||
expect(service.isConnected, isFalse);
|
||||
expect(service.activeEndpoint, isNull);
|
||||
});
|
||||
|
||||
test('emits only RX frames from socket stream', () async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final acceptedSocket = Completer<Socket>();
|
||||
final service = TcpTransportService();
|
||||
final receivedFrames = <Uint8List>[];
|
||||
|
||||
final serverSub = server.listen((socket) {
|
||||
if (!acceptedSocket.isCompleted) {
|
||||
acceptedSocket.complete(socket);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
final frameSub = service.frameStream.listen(receivedFrames.add);
|
||||
|
||||
try {
|
||||
await service.connect(
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: server.port,
|
||||
);
|
||||
|
||||
final socket = await acceptedSocket.future.timeout(
|
||||
const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
socket.add(<int>[usbSerialTxFrameStart, 0x01, 0x00, 0x11]);
|
||||
socket.add(<int>[usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]);
|
||||
await socket.flush();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(receivedFrames, hasLength(1));
|
||||
expect(receivedFrames.single, orderedEquals(<int>[0x33, 0x44]));
|
||||
} finally {
|
||||
await service.disconnect();
|
||||
await frameSub.cancel();
|
||||
await serverSub.cancel();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'disconnect during in-flight connect keeps transport disconnected',
|
||||
() async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final service = TcpTransportService();
|
||||
final host = InternetAddress.loopbackIPv4.address;
|
||||
|
||||
try {
|
||||
await IOOverrides.runWithIOOverrides(() async {
|
||||
final connectFuture = service.connect(host: host, port: server.port);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
await service.disconnect();
|
||||
await connectFuture;
|
||||
|
||||
expect(service.isConnected, isFalse);
|
||||
expect(service.status, TcpTransportStatus.disconnected);
|
||||
expect(service.activeEndpoint, isNull);
|
||||
}, _DelayedConnectOverrides(const Duration(milliseconds: 120)));
|
||||
} finally {
|
||||
await service.disconnect();
|
||||
await server.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue