diff --git a/app/lib/main.dart b/app/lib/main.dart index 3e85995..ce51d7c 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -23,7 +23,13 @@ void main() { /// Meshtastic Reader root widget that configures theming and the home screen. class PotatoMeshReaderApp extends StatelessWidget { - const PotatoMeshReaderApp({super.key}); + const PotatoMeshReaderApp({ + super.key, + this.fetcher = fetchMessages, + }); + + /// Fetch function injected to simplify testing and offline previews. + final Future> Function() fetcher; @override Widget build(BuildContext context) { @@ -45,14 +51,20 @@ class PotatoMeshReaderApp extends StatelessWidget { ), ), ), - home: const MessagesScreen(), + home: MessagesScreen(fetcher: fetcher), ); } } /// Displays the fetched mesh messages and supports pull-to-refresh. class MessagesScreen extends StatefulWidget { - const MessagesScreen({super.key}); + const MessagesScreen({ + super.key, + this.fetcher = fetchMessages, + }); + + /// Fetch function used to load messages from the PotatoMesh API. + final Future> Function() fetcher; @override State createState() => _MessagesScreenState(); @@ -64,22 +76,42 @@ class _MessagesScreenState extends State { @override void initState() { super.initState(); - _future = fetchMessages(); + _future = widget.fetcher(); + } + + /// When the fetcher changes, reload the future so the widget reflects the + /// new data source on rebuilds. + @override + void didUpdateWidget(covariant MessagesScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.fetcher != widget.fetcher) { + setState(() { + _future = widget.fetcher(); + }); + } } /// Reloads the message feed and waits for completion for pull-to-refresh. + /// + /// Errors are intentionally swallowed so the [FutureBuilder] can surface them + /// via its `snapshot.error` state without bubbling an exception to the + /// gesture handler. Future _refresh() async { setState(() { - _future = fetchMessages(); + _future = widget.fetcher(); }); - await _future; + try { + await _future; + } catch (_) { + // Let the FutureBuilder display error UI without breaking the gesture. + } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('#BerlinMesh'), + title: const Text('Meshtastic Reader'), actions: [ IconButton( tooltip: 'Refresh', @@ -158,36 +190,45 @@ class ChatLine extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - child: RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: '$timeStr ', - style: const TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + timeStr, + style: const TextStyle( + color: Colors.grey, + fontWeight: FontWeight.w500, ), - TextSpan( - text: '$nick ', - style: TextStyle( - color: _nickColor(message.fromShort), - fontWeight: FontWeight.w600, - ), + ), + const SizedBox(width: 6), + Text( + nick, + style: TextStyle( + color: _nickColor(message.fromShort), + fontWeight: FontWeight.w600, ), - TextSpan( - text: message.text.isEmpty ? '⟂ (no text)' : message.text, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.text.isEmpty ? '⟂ (no text)' : message.text, + style: DefaultTextStyle.of(context).style, + ), + if (message.channelName != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '#${message.channelName}', + style: const TextStyle(color: Colors.tealAccent), + ), + ), + ], ), - if (message.channelName != null) ...[ - const TextSpan(text: ' '), - TextSpan( - text: '#${message.channelName}', - style: const TextStyle(color: Colors.tealAccent), - ), - ], - ], - ), + ), + ], ), ); } @@ -308,13 +349,22 @@ class MeshMessage { } /// Fetches the latest PotatoMesh messages and returns them sorted by receive time. -Future> fetchMessages() async { +/// +/// A custom [client] can be supplied for testing; otherwise a short-lived +/// [http.Client] is created and closed after the request completes. +Future> fetchMessages({http.Client? client}) async { final uri = Uri.https('potatomesh.net', '/api/messages', { 'limit': '100', 'encrypted': 'false', }); - final resp = await http.get(uri); + final httpClient = client ?? http.Client(); + final shouldClose = client == null; + + final resp = await httpClient.get(uri); + if (shouldClose) { + httpClient.close(); + } if (resp.statusCode != 200) { throw Exception('HTTP ${resp.statusCode}: ${resp.body}'); } diff --git a/app/test/mesh_message_test.dart b/app/test/mesh_message_test.dart index a380419..3d3049c 100644 --- a/app/test/mesh_message_test.dart +++ b/app/test/mesh_message_test.dart @@ -12,7 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:potato_mesh_reader/main.dart'; /// Unit tests for [MeshMessage] parsing and the sorting helper. @@ -43,6 +47,31 @@ void main() { expect(msg.snr, closeTo(5.25, 0.0001)); expect(msg.hopLimit, 3); }); + + test('handles invalid timestamps and non-numeric fields', () { + final msg = MeshMessage.fromJson({ + 'id': null, + 'rx_iso': 'not-a-date', + 'from_id': '', + 'to_id': '', + 'channel': 'abc', + 'portnum': 'TEXT', + 'text': '', + 'rssi': 'missing', + 'snr': 'noise', + 'hop_limit': null, + }); + + expect(msg.id, 0); + expect(msg.rxTime, isNull); + expect(msg.timeFormatted, '--:--'); + expect(msg.fromShort, '?'); + expect(msg.channel, isNull); + expect(msg.rssi, isNull); + expect(msg.snr, isNull); + expect(msg.hopLimit, isNull); + expect(msg.text, ''); + }); }); group('sortMessagesByRxTime', () { @@ -97,4 +126,45 @@ void main() { expect(sorted[1].id, unknownTime.id); }); }); + + group('fetchMessages', () { + test('parses, sorts, and returns API messages', () async { + final calls = []; + final client = MockClient((request) async { + calls.add(request.url); + return http.Response( + jsonEncode([ + {'id': 2, 'rx_iso': '2024-01-02T00:01:00Z', 'from_id': '!b', 'to_id': '^', 'channel': 1, 'portnum': 'TEXT', 'text': 'Later'}, + {'id': 1, 'rx_iso': '2024-01-01T23:59:00Z', 'from_id': '!a', 'to_id': '^', 'channel': 1, 'portnum': 'TEXT', 'text': 'Earlier'}, + ]), + 200, + ); + }); + + final messages = await fetchMessages(client: client); + + expect(calls.single.queryParameters['limit'], '100'); + expect(messages.first.id, 1); + expect(messages.last.id, 2); + expect(messages.first.fromShort, 'a'); + }); + + test('throws on non-200 responses', () async { + final client = MockClient((request) async => http.Response('nope', 500)); + + expect( + () => fetchMessages(client: client), + throwsA(isA()), + ); + }); + + test('throws on unexpected response shapes', () async { + final client = MockClient((request) async => http.Response('{"id":1}', 200)); + + expect( + () => fetchMessages(client: client), + throwsA(isA()), + ); + }); + }); } diff --git a/app/test/messages_screen_test.dart b/app/test/messages_screen_test.dart new file mode 100644 index 0000000..aa00e8a --- /dev/null +++ b/app/test/messages_screen_test.dart @@ -0,0 +1,173 @@ +// Copyright © 2025-26 l5yth & contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:potato_mesh_reader/main.dart'; + +/// Widget-level tests that exercise UI states and rendering branches. +void main() { + testWidgets('PotatoMeshReaderApp wires theming and home screen', (tester) async { + final fetchCalls = []; + Future> fakeFetch() async { + fetchCalls.add(1); + return [ + MeshMessage( + id: 1, + rxTime: DateTime.utc(2024, 1, 1, 12, 0), + rxIso: '2024-01-01T12:00:00Z', + fromId: '!tester', + toId: '^', + channel: 1, + channelName: 'Main', + portnum: 'TEXT', + text: 'Hello', + rssi: -50, + snr: 2.2, + hopLimit: 1, + ), + ]; + } + + await tester.pumpWidget(PotatoMeshReaderApp(fetcher: fakeFetch)); + await tester.pumpAndSettle(); + + expect(find.text('Meshtastic Reader'), findsOneWidget); + expect(find.byType(MessagesScreen), findsOneWidget); + expect(fetchCalls.length, 1); + }); + + testWidgets('MessagesScreen shows loading, data, refresh, and empty states', (tester) async { + var fetchCount = 0; + final completer = Completer>(); + Future> fetcher() { + fetchCount += 1; + if (fetchCount == 1) { + return completer.future; + } + if (fetchCount == 2) { + return Future.value([ + MeshMessage( + id: 2, + rxTime: DateTime.utc(2024, 1, 1, 10, 0), + rxIso: '2024-01-01T10:00:00Z', + fromId: '!a', + toId: '^', + channel: 1, + channelName: null, + portnum: 'TEXT', + text: '', + rssi: -40, + snr: 1.1, + hopLimit: 1, + ), + ]); + } + return Future.error(StateError('no new data')); + } + + await tester.pumpWidget(MaterialApp(home: MessagesScreen(fetcher: fetcher))); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete([ + MeshMessage( + id: 1, + rxTime: DateTime.utc(2024, 1, 1, 9, 0), + rxIso: '2024-01-01T09:00:00Z', + fromId: '!nick', + toId: '^', + channel: 1, + channelName: 'General', + portnum: 'TEXT', + text: 'Loaded', + rssi: -42, + snr: 1.5, + hopLimit: 1, + ), + ]); + + await tester.pumpAndSettle(); + + expect(find.textContaining('Loaded'), findsOneWidget); + expect(find.textContaining('General'), findsOneWidget); + expect(fetchCount, 1); + + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(fetchCount, 2); + expect(find.text('⟂ (no text)'), findsOneWidget); + + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); + + expect(find.textContaining('Failed to load messages'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + home: MessagesScreen(fetcher: () async => []), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('No messages yet.'), findsOneWidget); + }); + + testWidgets('Settings button navigates to SettingsScreen', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: MessagesScreen(fetcher: () async => []), + ), + ); + + await tester.tap(find.byIcon(Icons.settings)); + await tester.pumpAndSettle(); + + expect(find.text('Settings (MVP)'), findsOneWidget); + expect(find.textContaining('Meshtastic Reader MVP'), findsOneWidget); + }); + + testWidgets('ChatLine renders placeholders and nick colour', (tester) async { + final message = MeshMessage( + id: 1, + rxTime: null, + rxIso: '', + fromId: '!ColorNick', + toId: '^', + channel: 1, + channelName: null, + portnum: 'TEXT', + text: '', + rssi: null, + snr: null, + hopLimit: null, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: ChatLine(message: message)), + ), + ); + + final nickText = find.textContaining(''); + final placeholder = find.text('⟂ (no text)'); + expect(nickText, findsOneWidget); + expect(placeholder, findsOneWidget); + expect(find.text('[--:--]'), findsOneWidget); + }); +}