Files
potato-mesh/app/test/messages_screen_test.dart
T
l5y 6f2db06f25 app: further harden refresh logic and prefer local first (#506)
* app: further harden refresh logic and prefer local first

* address review comments

* cover missing unit test vectors
2025-11-24 15:48:50 +01:00

323 lines
9.3 KiB
Dart

// 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:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:potato_mesh_reader/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Widget-level tests that exercise UI states and rendering branches.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
NodeShortNameCache.instance.allowRemoteLookups = false;
NodeShortNameCache.instance.clear();
});
testWidgets('PotatoMeshReaderApp wires theming and home screen',
(tester) async {
final fetchCalls = <int>[];
Future<List<MeshMessage>> fakeFetch({
http.Client? client,
String domain = 'potatomesh.net',
}) 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,
),
];
}
Future<BootstrapResult> bootstrapper({ProgressCallback? onProgress}) async {
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
return BootstrapResult(
instances: const [],
nodes: const [],
messages: await fakeFetch(domain: 'potatomesh.net'),
selectedDomain: 'potatomesh.net',
);
}
await tester.pumpWidget(PotatoMeshReaderApp(
fetcher: fakeFetch,
bootstrapper: bootstrapper,
enableAutoRefresh: false,
));
await tester.pumpAndSettle();
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
expect(find.byType(MessagesScreen), findsOneWidget);
expect(fetchCalls.length, greaterThanOrEqualTo(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;
}
return Future.value([
MeshMessage(
id: fetchCount,
rxTime: DateTime.utc(2024, 1, 1, 10, fetchCount),
rxIso: '2024-01-01T10:00:00Z',
fromId: '!a',
toId: '^',
channel: 1,
channelName: 'General',
portnum: 'TEXT',
text: 'Message $fetchCount',
rssi: -40,
snr: 1.1,
hopLimit: 1,
),
]);
}
await tester.pumpWidget(
MaterialApp(
home: MessagesScreen(
fetcher: fetcher,
domain: 'potatomesh.net',
enableAutoRefresh: false,
),
),
);
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(fetchCount, greaterThanOrEqualTo(2));
await tester.tap(find.byIcon(Icons.refresh));
await tester.pump();
await tester.pumpAndSettle();
expect(fetchCount, greaterThanOrEqualTo(3));
expect(find.textContaining('Message'), findsWidgets);
await tester.pumpWidget(
MaterialApp(
home: MessagesScreen(
fetcher: () async => [],
domain: 'potatomesh.net',
enableAutoRefresh: false,
),
),
);
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 => [],
domain: 'potatomesh.net',
enableAutoRefresh: false,
onOpenSettings: (context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingsScreen(
currentDomain: 'potatomesh.net',
onDomainChanged: (_) {},
loadInstances: ({bool refresh = false}) async => const [],
),
),
);
},
resetToken: 0,
),
),
);
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
expect(find.textContaining('PotatoMesh Reader'), findsOneWidget);
});
// Stale fetch completions are ignored by versioned fetch guard; covered
// indirectly by other tests that rely on append ordering.
testWidgets('changing endpoint triggers a refresh with new domain',
(tester) async {
final calls = <String>[];
Future<List<MeshMessage>> fetcher({
http.Client? client,
String domain = 'potatomesh.net',
}) async {
calls.add(domain);
return [
MeshMessage(
id: 1,
rxTime: null,
rxIso: '2024-01-01T00:00:00Z',
fromId: '!a',
toId: '^',
channel: 1,
channelName: 'Main',
portnum: 'TEXT',
text: domain,
rssi: null,
snr: null,
hopLimit: null,
)
];
}
Future<List<MeshInstance>> loader({bool refresh = false}) async => const [
MeshInstance(name: 'Mesh Berlin', domain: 'berlin.mesh'),
];
final mockClient = MockClient((request) async {
if (request.url.path.contains('/api/nodes')) {
return http.Response('[]', 200);
}
if (request.url.path.contains('/api/messages')) {
return http.Response('[]', 200);
}
if (request.url.path.contains('/api/instances')) {
return http.Response(
'[{"name":"Mesh Berlin","domain":"berlin.mesh"}]', 200);
}
return http.Response('[]', 200);
});
final repository = MeshRepository(client: mockClient);
Future<BootstrapResult> bootstrapper({ProgressCallback? onProgress}) async {
onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
final initialMessages = await fetcher(domain: 'potatomesh.net');
final instances = await loader();
await repository.updateInstances(instances);
return BootstrapResult(
instances: instances,
nodes: const [],
messages: initialMessages,
selectedDomain: 'potatomesh.net',
);
}
await tester.pumpWidget(
PotatoMeshReaderApp(
fetcher: fetcher,
instanceFetcher: ({http.Client? client}) => loader(),
bootstrapper: bootstrapper,
repository: repository,
enableAutoRefresh: false,
),
);
await tester.pumpAndSettle();
expect(calls.first, 'potatomesh.net');
expect(find.text('potatomesh.net'), findsOneWidget);
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
await tester.tap(find.byType(DropdownButtonFormField<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mesh Berlin').last);
await tester.pumpAndSettle();
await tester.pageBack();
final messagesFinder = find.byType(MessagesScreen);
for (var i = 0; i < 10 && messagesFinder.evaluate().isEmpty; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(messagesFinder, findsOneWidget);
expect(repository.selectedDomain, 'berlin.mesh');
expect(calls.contains('berlin.mesh'), isTrue);
expect(find.text('berlin.mesh'), findsOneWidget);
expect(find.text('potatomesh.net'), findsNothing);
expect(find.text('🥔 Mesh Berlin'), 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,
domain: 'potatomesh.net',
),
),
),
);
final nickText = find.textContaining('<!ColorNick>');
final placeholder = find.text('⟂ (no text)');
expect(nickText, findsOneWidget);
expect(placeholder, findsOneWidget);
expect(find.textContaining('[--:--]'), findsOneWidget);
});
}