mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 09:52:02 +02:00
6f2db06f25
* app: further harden refresh logic and prefer local first * address review comments * cover missing unit test vectors
323 lines
9.3 KiB
Dart
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);
|
|
});
|
|
}
|