Add comprehensive tests for Flutter reader (#491)

* Add comprehensive tests for Flutter reader

* Fix Flutter UI text expectations

* Expose chat placeholders to widget finders

* Handle refresh errors in messages screen

* Reset message fetch when widget updates
This commit is contained in:
l5y
2025-11-22 18:37:34 +01:00
committed by GitHub
parent 54fa1759d1
commit 0bb237c4ab
3 changed files with 329 additions and 36 deletions

View File

@@ -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<List<MeshMessage>> 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<List<MeshMessage>> Function() fetcher;
@override
State<MessagesScreen> createState() => _MessagesScreenState();
@@ -64,22 +76,42 @@ class _MessagesScreenState extends State<MessagesScreen> {
@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<void> _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<List<MeshMessage>> 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<List<MeshMessage>> 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}');
}

View File

@@ -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 = <Uri>[];
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<Exception>()),
);
});
test('throws on unexpected response shapes', () async {
final client = MockClient((request) async => http.Response('{"id":1}', 200));
expect(
() => fetchMessages(client: client),
throwsA(isA<Exception>()),
);
});
});
}

View File

@@ -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 = <int>[];
Future<List<MeshMessage>> 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<List<MeshMessage>>();
Future<List<MeshMessage>> 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('<ColorNick>');
final placeholder = find.text('⟂ (no text)');
expect(nickText, findsOneWidget);
expect(placeholder, findsOneWidget);
expect(find.text('[--:--]'), findsOneWidget);
});
}