mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
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:
@@ -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}');
|
||||
}
|
||||
|
||||
@@ -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>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
173
app/test/messages_screen_test.dart
Normal file
173
app/test/messages_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user