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
@@ -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());