mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
e6813a78fb
commit
98b5fa267f
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+2
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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')));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user