Add automated test suite for the Flutter frontend

Introduce a headless `flutter test` suite (83 tests) covering models,
utilities, every API endpoint, and the five screens, with shared helpers
and fixtures so new tests stay terse.

API endpoint methods are statically-dispatched extensions on the
BackendAPI singleton, so they cannot be mocked via `implements`. Mock at
the Dio HTTP-adapter layer (http_mock_adapter) instead, enabled by two
small @visibleForTesting seams: BackendAPI.dioForTesting swaps the
singleton's Dio, and BackendReachability.forceOnlineForTesting() forces a
deterministic online state so polling widgets load.

Add frontend/run_tests.sh and document it in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-03 19:41:52 -04:00
co-authored by Claude Opus 4.8
parent e6813a78fb
commit 98b5fa267f
33 changed files with 1690 additions and 2 deletions
+3 -1
View File
@@ -23,6 +23,7 @@ For Flutter/Dart code:
- `cd backend && ./run.sh` from the `backend/` folder - Run the backend
- `cd frontend && ./run.sh` from the `frontend/` folder - Run the front-end for the web
- `cd backend && ./run_tests.sh` - Run the backend tests
- `cd frontend && ./run_tests.sh` - Run the front-end tests
- `cd backend && ./lint.sh` - Run the clippy linter for Rust code
- `dart analyze` - Run the Dart linter
- `cd backend/data && ./update_mac_vendors --llm` - Update the MAC vendors list from the web and re-calculate the vedors -> device type list
@@ -49,7 +50,8 @@ For Flutter/Dart code:
## Important notes
- NEVER add or commit .env files or files with secrets (passwords, API keys or similar information)
- Code must be as simple as possible, human readable and modularized
- ALWAYS write unit tests for new or modified backend components
- ALWAYS write and/or update unit tests for new or modified backend components
- ALWAYS write and/or update unit, API and widget tests for new or modified frontend components
- ALWAYS run all tests after making a new change and do not continue until all tests pass
- When adding a new API endpoint, ALWAYS wire it to the OpenAPI generation
- When adding a significant chunk of new code (either Rust or Dart), run the corresponding linter
+1 -1
View File
@@ -17,7 +17,7 @@
## Frontend
- [ ] Can we add front-end tests?
- [x] Can we add front-end tests?
- [x] Break down oott_api.dart in modules
## Improve engine
@@ -38,6 +38,16 @@ class BackendReachability extends ChangeNotifier {
_prober = prober;
}
/// Test-only hook to force a deterministic online state so polling widgets
/// load during tests (where connectivity_plus has no platform backing).
@visibleForTesting
void forceOnlineForTesting() {
_deviceHasNetwork = true;
_isBackendReachable = true;
_lastErrorMessage = null;
notifyListeners();
}
void recordSuccess() {
_lastSuccessAt = DateTime.now();
_lastErrorMessage = null;
+13
View File
@@ -2,6 +2,7 @@ library;
import 'package:dio/dio.dart';
import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/foundation.dart';
import 'api/api_error.dart';
import 'api/dio_config.dart';
@@ -45,6 +46,18 @@ class BackendAPI {
BackendReachability.instance.setProber(() => _dio.get('/test'));
}
/// Test-only access to the underlying [Dio] so tests can swap in a client
/// with a mock adapter installed. Mirrors [reconfigureFromPrefs]'s prober
/// wiring so reachability stays consistent after a swap.
@visibleForTesting
Dio get dioForTesting => _dio;
@visibleForTesting
set dioForTesting(Dio dio) {
_dio = dio;
BackendReachability.instance.setProber(() => _dio.get('/test'));
}
// Returns null if the test was successful, and a String with a message about the issue if not
static Future<String?> test(String baseUrl, String apiKey) async {
final dio = buildDio(
+16
View File
@@ -240,6 +240,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_mock_adapter:
dependency: "direct dev"
description:
name: http_mock_adapter
sha256: "46399c78bd4a0af071978edd8c502d7aeeed73b5fb9860bca86b5ed647a63c1b"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
http_parser:
dependency: transitive
description:
@@ -312,6 +320,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logger:
dependency: transitive
description:
name: logger
sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
logging:
dependency: transitive
description:
+1
View File
@@ -26,6 +26,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
http_mock_adapter: ^0.6.1
flutter:
uses-material-design: true
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
flutter test
@@ -0,0 +1,18 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/oott_api.dart';
void main() {
// BackendAPI.test() builds its own Dio internally (no injectable seam), so
// only its error-mapping path is testable here: an unresolvable host must
// come back as a non-null, user-facing message rather than throwing. The
// success path is a documented coverage gap.
test('BackendAPI.test returns a user message for an unreachable backend',
() async {
// Loopback port 1 is closed: connection is refused immediately, keeping the
// test hermetic (no DNS, no TLS, no traffic leaving the machine).
final result = await BackendAPI.test('http://127.0.0.1:1/api', '');
expect(result, isNotNull);
expect(result, isNotEmpty);
});
}
+182
View File
@@ -0,0 +1,182 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/device_type.dart';
import 'package:frontend/utils/oott_api.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
test('getDevice fetches and decodes a single device', () async {
adapter.onGet(
'/devices/aa:bb:cc:dd:ee:ff',
(server) => server.reply(200, deviceJson(owner: 'carol')),
);
final device = await BackendAPI.instance.getDevice('aa:bb:cc:dd:ee:ff');
expect(device.owner, 'carol');
});
test('getDeviceSummary GETs /devices/summary', () async {
adapter.onGet(
'/devices/summary',
(server) => server.reply(200, deviceSummaryJson(totalRegistered: 7)),
);
final summary = await BackendAPI.instance.getDeviceSummary();
expect(summary.totalRegistered, 7);
});
test('listDevices sends filter + pagination params and trims extra item',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
deviceJson(macAddress: '00:00:00:00:00:03'),
]),
queryParameters: {
'is_registered': false,
'device_type': 'laptop',
'sort_by': 'last_seen',
'sort_order': 'asc',
'page_offset': 0,
'page_limit': 3,
},
);
final result = await BackendAPI.instance.listDevices(
isRegistered: false,
deviceType: DeviceType.laptop,
sortBy: 'last_seen',
sortAscending: true,
page: 0,
perPage: 2,
);
expect(result.items, hasLength(2));
expect(result.hasNextPage, isTrue);
});
test('listDevices reports no next page when fewer than perPage+1 returned',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [deviceJson()]),
);
final result = await BackendAPI.instance.listDevices(perPage: 2);
expect(result.items, hasLength(1));
expect(result.hasNextPage, isFalse);
});
test('listDevices maps the unknown device type to an empty filter', () async {
adapter.onGet(
'/devices',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {
'device_type': '',
'page_offset': 0,
'page_limit': 11,
},
);
final result = await BackendAPI.instance.listDevices(
deviceType: DeviceType.unknown,
);
expect(result.items, isEmpty);
});
test('registerDevice PUTs the device payload', () async {
adapter.onPut(
'/devices',
(server) => server.reply(200, null),
data: {
'mac_address': 'aa:bb:cc:dd:ee:ff',
'owner': 'dave',
'device_type': 'phone',
'name': 'Dave Phone',
},
);
await BackendAPI.instance.registerDevice(
'aa:bb:cc:dd:ee:ff',
'dave',
'phone',
name: 'Dave Phone',
);
});
test('updateDevice PUTs to the device path', () async {
adapter.onPut(
'/devices/aa:bb:cc:dd:ee:ff',
(server) => server.reply(200, null),
data: {
'owner': 'erin',
'device_type': 'tv',
'vendor': 'Globex',
'name': null,
},
);
await BackendAPI.instance.updateDevice(
'aa:bb:cc:dd:ee:ff',
'erin',
'tv',
'Globex',
);
});
test('forgetDevice DELETEs the device path', () async {
adapter.onDelete(
'/devices/aa:bb:cc:dd:ee:ff',
(server) => server.reply(200, null),
);
await BackendAPI.instance.forgetDevice('aa:bb:cc:dd:ee:ff');
});
test('getDeviceEvents decodes a list of events', () async {
adapter.onGet(
'/devices/aa:bb:cc:dd:ee:ff/events',
(server) => server.reply(200, [
deviceEventJson(id: 1, scanner: 'Arp'),
deviceEventJson(id: 2, scanner: 'Mdns'),
]),
);
final events = await BackendAPI.instance.getDeviceEvents(
'aa:bb:cc:dd:ee:ff',
);
expect(events, hasLength(2));
expect(events.first.scannerLabel, 'ARP');
});
test('getDeviceEvents sends created_from when provided', () async {
final from = DateTime.utc(2026, 5, 1, 0, 0, 0);
adapter.onGet(
'/devices/aa:bb:cc:dd:ee:ff/events',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {'created_from': from.toIso8601String()},
);
final events = await BackendAPI.instance.getDeviceEvents(
'aa:bb:cc:dd:ee:ff',
createdFrom: from,
);
expect(events, isEmpty);
});
}
@@ -0,0 +1,89 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/oott_api.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
test('markNotificationAsRead GETs the notification path', () async {
adapter.onGet(
'/notifications/7',
(server) => server.reply(200, null),
);
await BackendAPI.instance.markNotificationAsRead(7);
});
test('markNotificationAsNew POSTs to mark_as_new', () async {
adapter.onPost(
'/notifications/7/mark_as_new',
(server) => server.reply(200, null),
);
await BackendAPI.instance.markNotificationAsNew(7);
});
test('markAllNotificationsAsRead POSTs to mark_all_as_old', () async {
adapter.onPost(
'/notifications/mark_all_as_old',
(server) => server.reply(200, null),
);
await BackendAPI.instance.markAllNotificationsAsRead();
});
test('listNotifications sends is_new + pagination and trims extra item',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1),
notificationJson(id: 2),
notificationJson(id: 3),
]),
queryParameters: {
'is_new': true,
'page_offset': 0,
'page_limit': 3,
},
);
final result = await BackendAPI.instance.listNotifications(
true,
page: 0,
perPage: 2,
);
expect(result.items, hasLength(2));
expect(result.hasNextPage, isTrue);
});
test('listNotifications maps a null filter to an empty is_new param',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {
'is_new': '',
'page_offset': 10,
'page_limit': 6,
},
);
final result = await BackendAPI.instance.listNotifications(
null,
page: 2,
perPage: 5,
);
expect(result.items, isEmpty);
expect(result.hasNextPage, isFalse);
});
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/oott_api.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
test('getArpScannerStatus decodes /arp_scanner/status', () async {
adapter.onGet(
'/arp_scanner/status',
(server) => server.reply(
200,
intervalScannerJson(isRunning: true, runningForSeconds: 12),
),
);
final status = await BackendAPI.instance.getArpScannerStatus();
expect(status.isRunning, isTrue);
expect(status.runningForSeconds, 12.0);
});
test('getSnmpScannerStatus decodes /snmp_scanner/status', () async {
adapter.onGet(
'/snmp_scanner/status',
(server) => server.reply(200, intervalScannerJson(isRunning: false)),
);
final status = await BackendAPI.instance.getSnmpScannerStatus();
expect(status.isRunning, isFalse);
});
test('getMdnsScannerStatus decodes /mdns_scanner/status', () async {
adapter.onGet(
'/mdns_scanner/status',
(server) => server.reply(
200,
listenerScannerJson(isListening: true, devicesSeen: 3),
),
);
final status = await BackendAPI.instance.getMdnsScannerStatus();
expect(status.isListening, isTrue);
expect(status.devicesSeen, 3);
});
test('getSsdpScannerStatus decodes /ssdp_scanner/status', () async {
adapter.onGet(
'/ssdp_scanner/status',
(server) => server.reply(200, listenerScannerJson(devicesSeen: 1)),
);
final status = await BackendAPI.instance.getSsdpScannerStatus();
expect(status.devicesSeen, 1);
});
test('getDhcpScannerStatus decodes /dhcp_scanner/status', () async {
adapter.onGet(
'/dhcp_scanner/status',
(server) => server.reply(
200,
listenerScannerJson(isListening: true, devicesSeen: 5),
),
);
final status = await BackendAPI.instance.getDhcpScannerStatus();
expect(status.isListening, isTrue);
expect(status.devicesSeen, 5);
});
}
+12
View File
@@ -0,0 +1,12 @@
import 'dart:async';
import 'package:google_fonts/google_fonts.dart';
/// Global test bootstrap, picked up automatically by `flutter test` for every
/// test under `test/`. Disables Google Fonts runtime fetching so no test can
/// accidentally hit the network for a font (tests render screens directly
/// rather than through the app shell, but this is a cheap safety net).
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
GoogleFonts.config.allowRuntimeFetching = false;
await testMain();
}
@@ -0,0 +1,67 @@
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/backend_reachability.dart';
import 'package:frontend/utils/oott_api.dart';
import 'package:frontend/utils/pref_utils.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _connectivityMethodChannel = MethodChannel(
'dev.fluttercommunity.plus/connectivity',
);
const _connectivityEventChannel = EventChannel(
'dev.fluttercommunity.plus/connectivity_status',
);
bool _channelsStubbed = false;
/// Neutralises the connectivity_plus platform channels so the
/// [BackendReachability] singleton can be constructed in tests without the
/// plugin throwing `MissingPluginException`. `check` reports a network, and
/// the change stream stays open but emits nothing.
void _stubConnectivityChannels() {
if (_channelsStubbed) return;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(_connectivityMethodChannel, (call) async {
if (call.method == 'check') return <String>['wifi'];
return null;
});
messenger.setMockStreamHandler(
_connectivityEventChannel,
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
_channelsStubbed = true;
}
/// Prepares the backend singletons for a test and returns a [DioAdapter] so the
/// test can stub HTTP routes. Call from `setUp`.
///
/// This (1) seeds mock SharedPreferences, (2) stubs the connectivity channels,
/// (3) forces [BackendReachability] online so polling widgets load, and
/// (4) swaps the [BackendAPI] singleton's Dio for one backed by a mock adapter.
///
/// `prefs` are only honoured on the first call within a test isolate, because
/// `PrefUtil` caches the SharedPreferences instance after its one-shot init.
/// Use [PrefUtil.setValue] for per-test changes after that.
Future<DioAdapter> setUpBackendForTest({
Map<String, Object> prefs = const {},
}) async {
TestWidgetsFlutterBinding.ensureInitialized();
_stubConnectivityChannels();
SharedPreferences.setMockInitialValues({
'base_url': 'http://test.local/api',
'api_key': '',
...prefs,
});
await PrefUtil.init();
BackendReachability.instance.forceOnlineForTesting();
final dio = Dio(BaseOptions(baseUrl: 'http://test.local/api'));
final adapter = DioAdapter(dio: dio);
BackendAPI.instance.dioForTesting = dio;
return adapter;
}
+103
View File
@@ -0,0 +1,103 @@
/// Typed JSON builders mirroring the backend response shapes, so route stubs
/// and model tests stay terse and use the exact snake_case keys the app's
/// `fromJson` constructors read. Every field has a sensible default; override
/// only what a given test cares about.
library;
Map<String, dynamic> deviceJson({
String macAddress = 'aa:bb:cc:dd:ee:ff',
String ipv4Address = '192.168.0.10',
String vendor = 'Acme Corp',
String lastSeen = '2026-06-01T12:00:00Z',
bool isRegistered = false,
String owner = 'alice',
String deviceType = 'laptop',
String? name,
}) => {
'mac_address': macAddress,
'ipv4_address': ipv4Address,
'vendor': vendor,
'last_seen': lastSeen,
'is_registered': isRegistered,
'owner': owner,
'device_type': deviceType,
'name': name,
};
Map<String, dynamic> deviceSummaryJson({
int totalRegistered = 0,
int seenLastDayRegistered = 0,
int seenLastDayUnregistered = 0,
int seenLastWeekRegistered = 0,
int seenLastWeekUnregistered = 0,
}) => {
'total_registered': totalRegistered,
'seen_last_day_registered': seenLastDayRegistered,
'seen_last_day_unregistered': seenLastDayUnregistered,
'seen_last_week_registered': seenLastWeekRegistered,
'seen_last_week_unregistered': seenLastWeekUnregistered,
};
Map<String, dynamic> deviceEventJson({
int id = 1,
String macAddress = 'aa:bb:cc:dd:ee:ff',
String createdOn = '2026-06-01T12:00:00Z',
String eventType = 'seen',
String ipv4Address = '192.168.0.10',
String vendor = 'Acme Corp',
String scanner = 'Arp',
}) => {
'id': id,
'mac_address': macAddress,
'created_on': createdOn,
'event_type': eventType,
'ipv4_address': ipv4Address,
'vendor': vendor,
'scanner': scanner,
};
Map<String, dynamic> notificationJson({
int id = 1,
String createdOn = '2026-06-01T12:00:00Z',
String notificationType = 'newDeviceFound',
String title = 'New device found',
String body = 'A new device joined the network',
bool isNew = true,
String? macAddress = 'aa:bb:cc:dd:ee:ff',
}) => {
'id': id,
'created_on': createdOn,
'notification_type': notificationType,
'title': title,
'body': body,
'is_new': isNew,
'mac_address': macAddress,
};
/// Shape shared by the ARP and SNMP scanners (run-on-interval scanners).
Map<String, dynamic> intervalScannerJson({
bool isRunning = false,
double? runningForSeconds,
double? nextRunInSeconds,
int? lastScanDevicesSeen,
double? lastScanSecondsAgo,
}) => {
'is_running': isRunning,
'running_for_seconds': runningForSeconds,
'next_run_in_seconds': nextRunInSeconds,
'last_scan_devices_seen': lastScanDevicesSeen,
'last_scan_seconds_ago': lastScanSecondsAgo,
};
/// Shape shared by the mDNS, SSDP and DHCP scanners (passive listeners).
Map<String, dynamic> listenerScannerJson({
bool isListening = false,
double? listeningForSeconds,
int devicesSeen = 0,
double? lastDeviceSeenSecondsAgo,
}) => {
'is_listening': isListening,
'listening_for_seconds': listeningForSeconds,
'devices_seen': devicesSeen,
'last_device_seen_seconds_ago': lastDeviceSeenSecondsAgo,
};
+60
View File
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/main.dart';
import 'package:frontend/theme/catppuccin_mocha_theme.dart';
import 'package:provider/provider.dart';
/// Pumps a single screen/widget under test inside the minimal scaffolding the
/// app's widgets expect: an [AppState] provider (read by Settings) and a
/// [MaterialApp] carrying a real app theme. The real theme is required because
/// several widgets read `Theme.of(context).extension<AppColorExtension>()!`
/// non-null and would crash under a bare [ThemeData].
///
/// Widgets are pumped directly (never through the router shell) so the AppBar's
/// Google Fonts lookup is never reached.
Future<void> pumpScreen(
WidgetTester tester,
Widget child, {
Size size = const Size(900, 1200),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => AppState(),
child: MaterialApp(
theme: catppuccinMochaDarkTheme,
home: Scaffold(body: child),
),
),
);
}
/// Pumps frames (advancing fake time in small steps) until [finder] matches or
/// [maxFrames] is reached. Needed because Dio resolves each request through an
/// internal zero-duration timer, so the response only lands once the fake clock
/// is advanced — `pumpAndSettle` is unusable here as the polling widgets keep a
/// periodic timer alive forever.
Future<void> pumpUntilFound(
WidgetTester tester,
Finder finder, {
int maxFrames = 40,
Duration step = const Duration(milliseconds: 10),
}) async {
for (var i = 0; i < maxFrames; i++) {
if (finder.evaluate().isNotEmpty) return;
await tester.pump(step);
}
if (finder.evaluate().isEmpty) {
fail('Timed out waiting for $finder');
}
}
/// Unmounts the widget tree so widgets' `dispose()` cancel their timers and the
/// test ends without "pending timer" failures. Call at the end of widget tests
/// that exercise polling widgets.
Future<void> tearDownTree(WidgetTester tester) =>
tester.pumpWidget(const SizedBox());
+86
View File
@@ -0,0 +1,86 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/api/api_error.dart';
void main() {
final options = RequestOptions(path: '/x');
DioException dio(DioExceptionType type, {int? status}) => DioException(
requestOptions: options,
type: type,
response: status == null
? null
: Response(requestOptions: options, statusCode: status),
);
test('maps shape errors', () {
expect(
dioErrorToUserMessage(TypeError()),
'Unexpected response shape from backend.',
);
expect(
dioErrorToUserMessage(const FormatException('bad')),
'Unexpected response shape from backend.',
);
});
test('maps non-Dio errors to a generic message', () {
expect(dioErrorToUserMessage(Exception('boom')), 'Unexpected error.');
});
test('maps timeouts', () {
expect(
dioErrorToUserMessage(dio(DioExceptionType.connectionTimeout)),
'Backend did not respond in time.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.receiveTimeout)),
'Backend did not respond in time.',
);
});
test('maps connection and certificate errors', () {
expect(
dioErrorToUserMessage(dio(DioExceptionType.connectionError)),
'Cannot reach backend. Check the base URL and your network.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.badCertificate)),
'Backend TLS certificate could not be verified.',
);
});
test('maps bad responses by status code', () {
expect(
dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 401)),
'Authentication failed. Check your API key in Settings.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 403)),
'Authentication failed. Check your API key in Settings.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 404)),
'Not found.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 503)),
'Backend error (status 503). Please try again.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.badResponse, status: 418)),
'Unexpected response from backend (status 418).',
);
});
test('maps cancellation and unknown errors', () {
expect(
dioErrorToUserMessage(dio(DioExceptionType.cancel)),
'Request canceled.',
);
expect(
dioErrorToUserMessage(dio(DioExceptionType.unknown)),
'Unexpected error contacting backend.',
);
});
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/device_event.dart';
import '../helpers/fixtures.dart';
void main() {
test('DeviceEvent.fromJson maps all fields', () {
final event = DeviceEvent.fromJson(
deviceEventJson(
id: 42,
macAddress: '11:22:33:44:55:66',
createdOn: '2026-05-20T10:30:00Z',
eventType: 'changed',
ipv4Address: '10.0.0.9',
vendor: 'Initech',
scanner: 'Ssdp',
),
);
expect(event.id, 42);
expect(event.macAddress, '11:22:33:44:55:66');
expect(event.createdOn, DateTime.parse('2026-05-20T10:30:00Z'));
expect(event.eventType, 'changed');
expect(event.ipv4Address, '10.0.0.9');
expect(event.vendor, 'Initech');
expect(event.scanner, 'Ssdp');
});
test('scannerLabel humanises known scanner names', () {
expect(_label('Arp'), 'ARP');
expect(_label('Mdns'), 'mDNS');
expect(_label('Ssdp'), 'SSDP/UPnP');
expect(_label('Dhcp'), 'DHCP');
expect(_label('Snmp'), 'SNMP');
});
test('scannerLabel passes through unknown scanner names', () {
expect(_label('Custom'), 'Custom');
});
}
String _label(String scanner) =>
DeviceEvent.fromJson(deviceEventJson(scanner: scanner)).scannerLabel;
@@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/device_summary.dart';
import '../helpers/fixtures.dart';
void main() {
test('DeviceSummary.fromJson maps every count field', () {
final summary = DeviceSummary.fromJson(
deviceSummaryJson(
totalRegistered: 10,
seenLastDayRegistered: 4,
seenLastDayUnregistered: 2,
seenLastWeekRegistered: 8,
seenLastWeekUnregistered: 5,
),
);
expect(summary.totalRegistered, 10);
expect(summary.seenLastDayRegistered, 4);
expect(summary.seenLastDayUnregistered, 2);
expect(summary.seenLastWeekRegistered, 8);
expect(summary.seenLastWeekUnregistered, 5);
});
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/device.dart';
import 'package:frontend/model/device_type.dart';
import '../helpers/fixtures.dart';
void main() {
group('Device.fromJson', () {
test('maps all snake_case fields', () {
final device = Device.fromJson(
deviceJson(
macAddress: '11:22:33:44:55:66',
ipv4Address: '10.0.0.5',
vendor: 'Globex',
lastSeen: '2026-05-30T08:15:00Z',
isRegistered: true,
owner: 'bob',
deviceType: 'network_appliance',
name: 'Router',
),
);
expect(device.macAddress, '11:22:33:44:55:66');
expect(device.ipv4Address, '10.0.0.5');
expect(device.vendor, 'Globex');
expect(device.lastSeen, DateTime.parse('2026-05-30T08:15:00Z'));
expect(device.isRegistered, isTrue);
expect(device.owner, 'bob');
expect(device.deviceType, DeviceType.networkAppliance);
expect(device.name, 'Router');
});
test('leaves name null when absent', () {
final device = Device.fromJson(deviceJson());
expect(device.name, isNull);
});
test('falls back to unknown device type for unrecognised values', () {
final device = Device.fromJson(deviceJson(deviceType: 'spaceship'));
expect(device.deviceType, DeviceType.unknown);
});
});
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/device_type.dart';
void main() {
test('fromString parses every snake_case API name', () {
expect(DeviceType.fromString('phone'), DeviceType.phone);
expect(DeviceType.fromString('network_appliance'), DeviceType.networkAppliance);
expect(DeviceType.fromString('home_security'), DeviceType.homeSecurity);
expect(DeviceType.fromString('home_appliance'), DeviceType.homeAppliance);
expect(DeviceType.fromString('gaming_console'), DeviceType.gamingConsole);
});
test('fromString is case-insensitive', () {
expect(DeviceType.fromString('TV'), DeviceType.tv);
});
test('fromString falls back to unknown', () {
expect(DeviceType.fromString('toaster'), DeviceType.unknown);
});
test('apiName mirrors the snake_case backend identifiers', () {
expect(DeviceType.networkAppliance.apiName, 'network_appliance');
expect(DeviceType.gamingConsole.apiName, 'gaming_console');
expect(DeviceType.phone.apiName, 'phone');
});
test('apiName round-trips through fromString', () {
for (final type in DeviceType.values) {
if (type == DeviceType.unknown) continue;
expect(DeviceType.fromString(type.apiName), type);
}
});
test('label special-cases acronyms', () {
expect(DeviceType.tv.label, 'TV');
expect(DeviceType.pc.label, 'PC');
expect(DeviceType.networkAppliance.label, 'Network Appliance');
});
}
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/duration_formatter.dart';
void main() {
test('renders sub-minute values in seconds', () {
expect(formatSeconds(0), '0s');
expect(formatSeconds(59), '59s');
});
test('rounds fractional seconds to the nearest whole second', () {
expect(formatSeconds(5.4), '5s');
expect(formatSeconds(5.6), '6s');
});
test('renders minute-and-second values past 60 seconds', () {
expect(formatSeconds(60), '1m 0s');
expect(formatSeconds(125), '2m 5s');
});
test('rounding up to a full minute carries into the minutes part', () {
expect(formatSeconds(59.6), '1m 0s');
});
test('clamps negative values to zero', () {
expect(formatSeconds(-10), '0s');
});
}
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/friendly_date_formatter.dart';
void main() {
final formatter = FriendlyDateFormatter();
final now = DateTime.now();
test('renders very recent times as "Just now"', () {
expect(formatter.format(now.subtract(const Duration(seconds: 30))), 'Just now');
expect(formatter.format(now.subtract(const Duration(minutes: 2))), 'Just now');
});
test('renders times under an hour as relative minutes', () {
expect(
formatter.format(now.subtract(const Duration(minutes: 30))),
'30 minutes ago',
);
});
test('renders earlier-today / late-yesterday times with a clock time', () {
final candidate = now.subtract(const Duration(hours: 2));
final sameDay = candidate.year == now.year &&
candidate.month == now.month &&
candidate.day == now.day;
expect(
formatter.format(candidate),
startsWith(sameDay ? 'Today at' : 'Yesterday at'),
);
});
test('renders a yesterday-noon time as "Yesterday at"', () {
final yesterdayNoon =
DateTime(now.year, now.month, now.day - 1, 12, 0);
expect(formatter.format(yesterdayNoon), startsWith('Yesterday at'));
});
test('renders older dates with the full date', () {
final threeDaysAgo = DateTime(now.year, now.month, now.day - 3, 12, 0);
final result = formatter.format(threeDaysAgo);
expect(result, contains(threeDaysAgo.year.toString()));
expect(result, isNot(startsWith('Today')));
expect(result, isNot(startsWith('Yesterday')));
});
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/notification.dart';
import 'package:frontend/model/notification_type.dart';
import '../helpers/fixtures.dart';
void main() {
group('Notification.fromJson', () {
test('maps all fields', () {
final notification = Notification.fromJson(
notificationJson(
id: 9,
createdOn: '2026-05-15T09:00:00Z',
notificationType: 'deviceChanged',
title: 'Device changed',
body: 'Something changed',
isNew: false,
macAddress: '11:22:33:44:55:66',
),
);
expect(notification.id, 9);
expect(notification.createdOn, DateTime.parse('2026-05-15T09:00:00Z'));
expect(notification.notificationType, NotificationType.deviceChanged);
expect(notification.title, 'Device changed');
expect(notification.body, 'Something changed');
expect(notification.isNew, isFalse);
expect(notification.macAddress, '11:22:33:44:55:66');
});
test('leaves macAddress null when absent', () {
final notification = Notification.fromJson(
notificationJson(macAddress: null),
);
expect(notification.macAddress, isNull);
});
});
test('toJson round-trips back through fromJson', () {
final original = Notification.fromJson(notificationJson(id: 3));
final restored = Notification.fromJson(original.toJson());
expect(restored.id, original.id);
expect(restored.title, original.title);
expect(restored.body, original.body);
expect(restored.isNew, original.isNew);
expect(restored.notificationType, original.notificationType);
expect(restored.createdOn, original.createdOn);
expect(restored.macAddress, original.macAddress);
});
test('copyWith only overrides isNew', () {
final original = Notification.fromJson(notificationJson(isNew: true));
final updated = original.copyWith(isNew: false);
expect(updated.isNew, isFalse);
expect(updated.id, original.id);
expect(updated.title, original.title);
expect(original.isNew, isTrue, reason: 'original is not mutated');
});
}
@@ -0,0 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/notification_type.dart';
void main() {
test('fromString parses known types case-insensitively', () {
expect(
NotificationType.fromString('newDeviceFound'),
NotificationType.newDeviceFound,
);
expect(
NotificationType.fromString('deviceonlineaftertime'),
NotificationType.deviceOnlineAfterTime,
);
expect(
NotificationType.fromString('DeviceChanged'),
NotificationType.deviceChanged,
);
});
test('fromString falls back to other', () {
expect(NotificationType.fromString('whatever'), NotificationType.other);
});
test('name round-trips through fromString', () {
for (final type in NotificationType.values) {
if (type == NotificationType.other) continue;
expect(NotificationType.fromString(type.name), type);
}
});
}
+134
View File
@@ -0,0 +1,134 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/utils/backend_reachability.dart';
import 'package:frontend/utils/polled_value.dart';
import '../helpers/backend_test_harness.dart';
void main() {
late List<Completer<int>> completers;
setUp(() async {
completers = [];
await setUpBackendForTest();
});
// A PolledValue whose every fetch is resolved manually through [completers].
// The poll interval is effectively disabled so only explicit reloads fetch.
PolledValue<int> makePolled({
Duration staleErrorAfter = const Duration(seconds: 30),
}) {
return PolledValue<int>(
fetch: ({cancelToken}) {
final completer = Completer<int>();
completers.add(completer);
return completer.future;
},
pollInterval: const Duration(hours: 1),
staleErrorAfter: staleErrorAfter,
);
}
DioException connectionError() => DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionError,
);
test('starts in initialLoading then becomes fresh on success', () async {
final polled = makePolled();
addTearDown(polled.dispose);
expect(polled.freshness, PolledFreshness.initialLoading);
expect(polled.value, isNull);
completers[0].complete(5);
await pumpEventQueue();
expect(polled.freshness, PolledFreshness.fresh);
expect(polled.value, 5);
});
test('becomes error when the first load fails with no prior value', () async {
final polled = makePolled();
addTearDown(polled.dispose);
completers[0].completeError(connectionError());
await pumpEventQueue();
expect(polled.freshness, PolledFreshness.error);
expect(polled.value, isNull);
expect(polled.lastErrorMessage, isNotNull);
});
test('a failure after a success is stale, then error past staleErrorAfter',
() async {
final polled = makePolled(staleErrorAfter: const Duration(seconds: 1));
addTearDown(polled.dispose);
completers[0].complete(1);
await pumpEventQueue();
expect(polled.freshness, PolledFreshness.fresh);
// Force a second load and fail it.
polled.pause();
polled.resume();
await pumpEventQueue();
completers[1].completeError(connectionError());
await pumpEventQueue();
expect(polled.value, 1, reason: 'keeps the last good value');
expect(polled.freshness, PolledFreshness.stale);
await Future<void>.delayed(const Duration(milliseconds: 1200));
expect(polled.freshness, PolledFreshness.error);
});
test('effectiveFreshness downgrades error to stale while offline', () async {
final polled = makePolled(staleErrorAfter: const Duration(milliseconds: 1));
addTearDown(polled.dispose);
completers[0].complete(1);
await pumpEventQueue();
polled.pause();
polled.resume();
await pumpEventQueue();
completers[1].completeError(connectionError());
await pumpEventQueue();
await Future<void>.delayed(const Duration(milliseconds: 5));
// Base freshness is error (value present, but past the 1ms threshold).
expect(polled.freshness, PolledFreshness.error);
// Online: effective freshness mirrors the base error.
expect(effectiveFreshness(polled), PolledFreshness.error);
// Offline with a value present: downgraded to stale.
BackendReachability.instance.recordFailure(connectionError());
expect(BackendReachability.instance.isOnline, isFalse);
expect(effectiveFreshness(polled), PolledFreshness.stale);
});
test('pause/resume is reference counted across reasons', () async {
final polled = makePolled();
addTearDown(polled.dispose);
completers[0].complete(1);
await pumpEventQueue();
expect(completers, hasLength(1));
polled.pauseFor(PolledPauseReason.manual);
polled.pauseFor(PolledPauseReason.unreachable);
// Removing only one of two reasons must not reload.
polled.resumeFor(PolledPauseReason.manual);
await pumpEventQueue();
expect(completers, hasLength(1));
// Clearing the last reason reloads.
polled.resumeFor(PolledPauseReason.unreachable);
await pumpEventQueue();
expect(completers, hasLength(2));
});
}
@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/arp_scanner_status.dart';
import 'package:frontend/model/dhcp_scanner_status.dart';
import 'package:frontend/model/mdns_scanner_status.dart';
import 'package:frontend/model/snmp_scanner_status.dart';
import 'package:frontend/model/ssdp_scanner_status.dart';
import '../helpers/fixtures.dart';
void main() {
group('interval scanners (ARP, SNMP)', () {
test('ArpScannerStatus.fromJson maps populated fields', () {
final status = ArpScannerStatus.fromJson(
intervalScannerJson(
isRunning: true,
runningForSeconds: 12.5,
nextRunInSeconds: 30,
lastScanDevicesSeen: 7,
lastScanSecondsAgo: 5,
),
);
expect(status.isRunning, isTrue);
expect(status.runningForSeconds, 12.5);
expect(status.nextRunInSeconds, 30.0);
expect(status.lastScanDevicesSeen, 7);
expect(status.lastScanSecondsAgo, 5.0);
});
test('ArpScannerStatus.fromJson tolerates null optionals', () {
final status = ArpScannerStatus.fromJson(intervalScannerJson());
expect(status.isRunning, isFalse);
expect(status.runningForSeconds, isNull);
expect(status.nextRunInSeconds, isNull);
expect(status.lastScanDevicesSeen, isNull);
});
test('SnmpScannerStatus.fromJson maps fields', () {
final status = SnmpScannerStatus.fromJson(
intervalScannerJson(isRunning: true, runningForSeconds: 3),
);
expect(status.isRunning, isTrue);
expect(status.runningForSeconds, 3.0);
});
});
group('listener scanners (mDNS, SSDP, DHCP)', () {
test('MdnsScannerStatus.fromJson maps populated fields', () {
final status = MdnsScannerStatus.fromJson(
listenerScannerJson(
isListening: true,
listeningForSeconds: 99.0,
devicesSeen: 4,
lastDeviceSeenSecondsAgo: 8,
),
);
expect(status.isListening, isTrue);
expect(status.listeningForSeconds, 99.0);
expect(status.devicesSeen, 4);
expect(status.lastDeviceSeenSecondsAgo, 8.0);
});
test('SsdpScannerStatus.fromJson requires devicesSeen and tolerates nulls',
() {
final status = SsdpScannerStatus.fromJson(
listenerScannerJson(devicesSeen: 0),
);
expect(status.isListening, isFalse);
expect(status.devicesSeen, 0);
expect(status.listeningForSeconds, isNull);
expect(status.lastDeviceSeenSecondsAgo, isNull);
});
test('DhcpScannerStatus.fromJson maps fields', () {
final status = DhcpScannerStatus.fromJson(
listenerScannerJson(isListening: true, devicesSeen: 2),
);
expect(status.isListening, isTrue);
expect(status.devicesSeen, 2);
});
});
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/about/about.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
void main() {
setUp(() async {
await setUpBackendForTest();
});
testWidgets('renders the about content and release fallback', (tester) async {
await pumpScreen(tester, const About());
// PackageInfo.fromPlatform() has no plugin in tests, so the FutureBuilder
// falls back to the hard-coded release date.
await tester.pump();
expect(find.text('About OOTT'), findsOneWidget);
// Assert the fallback prefix only, not the release date literal (it changes
// every release).
expect(find.textContaining('Released'), findsOneWidget);
expect(find.text('Source code'), findsOneWidget);
expect(find.text('openssl'), findsOneWidget);
});
}
@@ -0,0 +1,60 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/devices/device_list.dart';
import 'package:frontend/devices/device_list_rows.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
testWidgets('renders device rows after loading', (tester) async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [
deviceJson(macAddress: '00:00:00:00:00:01', owner: 'alice'),
deviceJson(macAddress: '00:00:00:00:00:02', owner: 'bob'),
]),
);
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.byType(DeviceRowWide));
expect(find.byType(DeviceRowWide), findsNWidgets(2));
await tearDownTree(tester);
});
testWidgets('shows the empty message when there are no devices',
(tester) async {
adapter.onGet('/devices', (server) => server.reply(200, <dynamic>[]));
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.text('No unregistered devices'));
expect(find.text('No unregistered devices'), findsOneWidget);
expect(find.byType(DeviceRowWide), findsNothing);
await tearDownTree(tester);
});
testWidgets('shows an error message when the request fails', (tester) async {
adapter.onGet('/devices', (server) => server.reply(500, {'error': 'boom'}));
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(
tester,
find.textContaining('Backend error (status 500)'),
);
expect(find.textContaining('Backend error (status 500)'), findsOneWidget);
await tearDownTree(tester);
});
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/widgets/device_summary_card.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
testWidgets('shows a loading spinner before the summary arrives',
(tester) async {
adapter.onGet(
'/devices/summary',
(server) => server.reply(200, deviceSummaryJson()),
);
await pumpScreen(tester, const DeviceSummaryCard());
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Let the in-flight request resolve so no Dio timer is left pending.
await pumpUntilFound(tester, find.byType(Divider));
await tearDownTree(tester);
});
testWidgets('renders the summary numbers after loading', (tester) async {
adapter.onGet(
'/devices/summary',
(server) => server.reply(
200,
deviceSummaryJson(totalRegistered: 12, seenLastDayRegistered: 3),
),
);
await pumpScreen(tester, const DeviceSummaryCard());
await pumpUntilFound(tester, find.text('12'));
expect(find.text('12'), findsOneWidget);
expect(find.text('3'), findsWidgets);
await tearDownTree(tester);
});
testWidgets('shows the error message when the request fails', (tester) async {
adapter.onGet(
'/devices/summary',
(server) => server.reply(500, {'error': 'boom'}),
);
await pumpScreen(tester, const DeviceSummaryCard());
await pumpUntilFound(
tester,
find.textContaining('Backend error (status 500)'),
);
expect(
find.textContaining('Backend error (status 500)'),
findsOneWidget,
);
await tearDownTree(tester);
});
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/home/home_screen.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
void stubHomeEndpoints() {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1, title: 'New device found'),
]),
);
adapter.onGet(
'/devices/summary',
(server) => server.reply(200, deviceSummaryJson(totalRegistered: 5)),
);
adapter.onGet(
'/arp_scanner/status',
(server) => server.reply(200, intervalScannerJson(isRunning: true)),
);
adapter.onGet(
'/mdns_scanner/status',
(server) => server.reply(200, listenerScannerJson(isListening: true)),
);
adapter.onGet(
'/ssdp_scanner/status',
(server) => server.reply(200, listenerScannerJson()),
);
adapter.onGet(
'/dhcp_scanner/status',
(server) => server.reply(200, listenerScannerJson()),
);
adapter.onGet(
'/snmp_scanner/status',
(server) => server.reply(200, intervalScannerJson()),
);
}
testWidgets('shows notifications, the device summary and scanner statuses',
(tester) async {
stubHomeEndpoints();
// Below the 700px breakpoint the home screen uses its single-column layout,
// giving the cards full width and avoiding the cramped fixed-width side
// column (whose rows overflow under the test font metrics).
await pumpScreen(tester, const HomeScreen(), size: const Size(690, 2000));
await pumpUntilFound(tester, find.textContaining('New device found'));
expect(find.textContaining('New device found'), findsOneWidget);
expect(find.text('Registered in the system'), findsOneWidget);
expect(find.text('5'), findsOneWidget);
await tearDownTree(tester);
});
}
@@ -0,0 +1,57 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/widgets/scanner_status_card.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
ScannerStatus _resolve(BuildContext context, int value, double elapsed) =>
(color: Colors.green, label: 'Value $value', sublabels: ['detail line']);
void main() {
setUp(() async {
await setUpBackendForTest();
});
testWidgets('shows a spinner, then the resolved label and sublabels',
(tester) async {
await pumpScreen(
tester,
ScannerStatusCard<int>(
title: 'Probe',
fetch: ({cancelToken}) async => 42,
resolver: _resolve,
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await pumpUntilFound(tester, find.text('Value 42'));
expect(find.text('Probe'), findsOneWidget);
expect(find.text('detail line'), findsOneWidget);
await tearDownTree(tester);
});
testWidgets('renders an Error state when the fetch fails', (tester) async {
await pumpScreen(
tester,
ScannerStatusCard<int>(
title: 'Probe',
fetch: ({cancelToken}) async => throw DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionError,
),
resolver: _resolve,
),
);
await pumpUntilFound(tester, find.text('Error'));
expect(find.text('Error'), findsOneWidget);
await tearDownTree(tester);
});
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/settings/settings.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
void main() {
setUp(() async {
await setUpBackendForTest(
prefs: {
'base_url': 'http://my.server/api',
'api_key': XOR().xorEncode('topsecret'),
'theme': 'catppuccin_mocha',
},
);
});
testWidgets('prefills the form from stored preferences', (tester) async {
await pumpScreen(tester, const Settings());
expect(find.text('http://my.server/api'), findsOneWidget);
expect(find.text('Catppuccin Mocha'), findsOneWidget);
});
testWidgets('validates an empty base URL when testing the connection',
(tester) async {
await pumpScreen(tester, const Settings());
await tester.enterText(find.byType(TextFormField).first, '');
await tester.tap(find.widgetWithText(ElevatedButton, 'Test'));
await tester.pump();
expect(find.text('The URL cannot be empty'), findsOneWidget);
});
testWidgets('disables Save once the connection details are edited',
(tester) async {
await pumpScreen(tester, const Settings());
final saveButton = find.widgetWithText(ElevatedButton, 'Save');
expect(
tester.widget<ElevatedButton>(saveButton).onPressed,
isNotNull,
reason: 'enabled for the unmodified, prefilled form',
);
await tester.enterText(
find.byType(TextFormField).first,
'http://changed/api',
);
await tester.pump();
expect(
tester.widget<ElevatedButton>(saveButton).onPressed,
isNull,
reason: 'disabled until the new connection is tested',
);
});
testWidgets('saving the unmodified form shows a success message',
(tester) async {
await pumpScreen(tester, const Settings());
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
await pumpUntilFound(tester, find.text('Settings saved successfully'));
expect(find.text('Settings saved successfully'), findsOneWidget);
});
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/status/status_screen.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
void stubAllScanners() {
adapter.onGet(
'/arp_scanner/status',
(server) => server.reply(
200,
intervalScannerJson(isRunning: true, runningForSeconds: 10),
),
);
adapter.onGet(
'/mdns_scanner/status',
(server) => server.reply(
200,
listenerScannerJson(isListening: true, devicesSeen: 2),
),
);
adapter.onGet(
'/ssdp_scanner/status',
(server) => server.reply(200, listenerScannerJson()),
);
adapter.onGet(
'/dhcp_scanner/status',
(server) => server.reply(200, listenerScannerJson()),
);
adapter.onGet(
'/snmp_scanner/status',
(server) => server.reply(200, intervalScannerJson()),
);
}
testWidgets('renders each scanner card with its resolved status',
(tester) async {
stubAllScanners();
await pumpScreen(tester, const StatusScreen());
await pumpUntilFound(tester, find.text('Running'));
expect(find.text('Status'), findsOneWidget);
expect(find.text('ARP Scanner'), findsOneWidget);
expect(find.text('mDNS Scanner'), findsOneWidget);
expect(find.text('Running'), findsWidgets);
expect(find.text('Listening'), findsWidgets);
expect(find.byType(CircularProgressIndicator), findsNothing);
await tearDownTree(tester);
});
}