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
@@ -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());
|
||||
Reference in New Issue
Block a user