diff --git a/lib/core/errors/service_error.dart b/lib/core/errors/service_error.dart index b42a5564c..15fdc4909 100644 --- a/lib/core/errors/service_error.dart +++ b/lib/core/errors/service_error.dart @@ -290,3 +290,21 @@ final class StorageError extends ServiceError { final Object? originalError; const StorageError({this.originalError}); } + +// ============================================================================ +// Device/Router Errors +// ============================================================================ + +/// Serial number mismatch between expected and actual router +final class SerialNumberMismatchError extends ServiceError { + final String expected; + final String actual; + const SerialNumberMismatchError( + {required this.expected, required this.actual}); +} + +/// Router connectivity error (cannot reach router) +final class ConnectivityError extends ServiceError { + final String? message; + const ConnectivityError({this.message}); +} diff --git a/lib/core/jnap/providers/dashboard_manager_provider.dart b/lib/core/jnap/providers/dashboard_manager_provider.dart index 0624af085..e552bda11 100644 --- a/lib/core/jnap/providers/dashboard_manager_provider.dart +++ b/lib/core/jnap/providers/dashboard_manager_provider.dart @@ -1,15 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; import 'package:privacy_gui/constants/_constants.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/models/device_info.dart'; -import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; -import 'package:privacy_gui/core/jnap/models/radio_info.dart'; -import 'package:privacy_gui/core/jnap/models/soft_sku_settings.dart'; import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; -import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/jnap/services/dashboard_manager_service.dart'; import 'package:privacy_gui/core/utils/bench_mark.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -23,87 +17,10 @@ class DashboardManagerNotifier extends Notifier { @override DashboardManagerState build() { final coreTransactionData = ref.watch(pollingProvider).value; - return createState( - pollingResult: coreTransactionData, - ); + final service = ref.read(dashboardManagerServiceProvider); + return service.transformPollingData(coreTransactionData); } - DashboardManagerState createState({ - CoreTransactionData? pollingResult, - }) { - Map? getDeviceInfoData; - Map? getRadioInfoData; - Map? getGuestRadioSettingsData; - Map? getSystemStats; - Map? getEthernetPortConnections; - Map? getLocalTime; - - final result = pollingResult?.data; - if (result != null) { - getDeviceInfoData = - (result[JNAPAction.getDeviceInfo] as JNAPSuccess?)?.output; - getRadioInfoData = - (result[JNAPAction.getRadioInfo] as JNAPSuccess?)?.output; - getGuestRadioSettingsData = - (result[JNAPAction.getGuestRadioSettings] as JNAPSuccess?)?.output; - getSystemStats = - (result[JNAPAction.getSystemStats] as JNAPSuccess?)?.output; - getEthernetPortConnections = - (result[JNAPAction.getEthernetPortConnections] as JNAPSuccess?) - ?.output; - getLocalTime = (result[JNAPAction.getLocalTime] as JNAPSuccess?)?.output; - } - - var newState = const DashboardManagerState(); - if (getDeviceInfoData != null) { - newState = newState.copyWith( - deviceInfo: NodeDeviceInfo.fromJson(getDeviceInfoData)); - } - if (getRadioInfoData != null) { - newState = _getMainRadioList(newState, getRadioInfoData); - } - if (getGuestRadioSettingsData != null) { - newState = _getGuestRadioList(newState, getGuestRadioSettingsData); - } - - if (getSystemStats != null) { - final uptimeSeconds = getSystemStats['uptimeSeconds']; - final cpuLoad = getSystemStats['CPULoad']; - final memoryLoad = getSystemStats['MemoryLoad']; - newState = newState.copyWith( - uptimes: uptimeSeconds, cpuLoad: cpuLoad, memoryLoad: memoryLoad); - } - - if (getEthernetPortConnections != null) { - final lanPortConnections = - List.from(getEthernetPortConnections['lanPortConnections']); - final wanPortConnection = getEthernetPortConnections['wanPortConnection']; - newState = newState.copyWith( - lanConnections: lanPortConnections, wanConnection: wanPortConnection); - } - - String? timeString; - if (getLocalTime != null) { - timeString = getLocalTime['currentTime']; - } - - final localTime = (timeString != null - ? DateFormat("yyyy-MM-ddThh:mm:ssZ").tryParse(timeString) - : DateTime.now()) - ?.millisecondsSinceEpoch; - newState = newState.copyWith(localTime: localTime); - - final softSKUSettings = JNAPTransactionSuccessWrap.getResult( - JNAPAction.getSoftSKUSettings, result ?? {}); - if (softSKUSettings != null) { - final settings = SoftSKUSettings.fromMap(softSKUSettings.output); - newState = newState.copyWith(skuModelNumber: settings.modelNumber); - } - - return newState; - } - - // ... (other methods remain the same) Future saveSelectedNetwork( String serialNumber, String networkId) async { logger.i('[Prepare]: saveSelectedNetwork - $networkId, $serialNumber'); @@ -116,53 +33,21 @@ class DashboardManagerNotifier extends Notifier { } Future checkRouterIsBack() async { - NodeDeviceInfo? nodeDeviceInfo; - final routerRepository = ref.read(routerRepositoryProvider); - final result = await routerRepository.send(JNAPAction.getDeviceInfo, - fetchRemote: true, retries: 0); - nodeDeviceInfo = NodeDeviceInfo.fromJson(result.output); + final service = ref.read(dashboardManagerServiceProvider); final prefs = await SharedPreferences.getInstance(); final currentSN = prefs.getString(pCurrentSN) ?? prefs.getString(pPnpConfiguredSN); - if (currentSN == nodeDeviceInfo.serialNumber) { - return nodeDeviceInfo; - } else { - logger.d('[CheckRouterBack]: SN not match'); - throw Exception('[CheckRouterBack]: SN not match'); - } + return service.checkRouterIsBack(currentSN ?? ''); } Future checkDeviceInfo(String? serialNumber) async { final benchMark = BenchMarkLogger(name: 'checkDeviceInfo'); benchMark.start(); - NodeDeviceInfo? nodeDeviceInfo = state.deviceInfo; - if (nodeDeviceInfo == null) { - final routerRepository = ref.read(routerRepositoryProvider); - final result = await routerRepository.send(JNAPAction.getDeviceInfo, - retries: 0, timeoutMs: 3000); - nodeDeviceInfo = NodeDeviceInfo.fromJson(result.output); - } + final service = ref.read(dashboardManagerServiceProvider); + final nodeDeviceInfo = await service.checkDeviceInfo(state.deviceInfo); benchMark.end(); return nodeDeviceInfo; } - - // bool isHealthCheckModuleSupported(String module) { - // return state.healthCheckModules.contains(module); - // } - - DashboardManagerState _getMainRadioList( - DashboardManagerState state, Map data) { - final getRadioInfoData = GetRadioInfo.fromMap(data); - return state.copyWith(mainRadios: getRadioInfoData.radios); - } - - DashboardManagerState _getGuestRadioList( - DashboardManagerState state, Map data) { - final guestRadioSettings = GuestRadioSettings.fromMap(data); - return state.copyWith( - guestRadios: guestRadioSettings.radios, - isGuestNetworkEnabled: guestRadioSettings.isGuestNetworkEnabled); - } } final selectedNetworkIdProvider = StateProvider((ref) { diff --git a/lib/core/jnap/providers/device_manager_provider.dart b/lib/core/jnap/providers/device_manager_provider.dart index 735caabd7..01830a9b1 100644 --- a/lib/core/jnap/providers/device_manager_provider.dart +++ b/lib/core/jnap/providers/device_manager_provider.dart @@ -1,20 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/command/base_command.dart'; -import 'package:privacy_gui/core/jnap/extensions/_extensions.dart'; -import 'package:privacy_gui/core/jnap/models/back_haul_info.dart'; -import 'package:privacy_gui/core/jnap/models/device.dart'; -import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; -import 'package:privacy_gui/core/jnap/models/layer2_connection.dart'; -import 'package:privacy_gui/core/jnap/models/node_wireless_connection.dart'; -import 'package:privacy_gui/core/jnap/models/radio_info.dart'; -import 'package:privacy_gui/core/jnap/models/wan_status.dart'; -import 'package:privacy_gui/core/jnap/models/wirless_connection.dart'; import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; -import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/jnap/services/device_manager_service.dart'; import 'package:privacy_gui/core/utils/devices.dart'; import 'package:privacy_gui/core/utils/icon_device_category.dart'; @@ -27,275 +15,12 @@ class DeviceManagerNotifier extends Notifier { @override DeviceManagerState build() { final coreTransactionData = ref.watch(pollingProvider).value; - return createState(pollingResult: coreTransactionData); + final service = ref.read(deviceManagerServiceProvider); + return service.transformPollingData(coreTransactionData); } void init() { - state = DeviceManagerState(); - } - - DeviceManagerState createState({CoreTransactionData? pollingResult}) { - Map? getNetworkConnectionsData; - Map? getNodesWirelessNetworkConnectionsData; - Map? getRadioInfo; - Map? guestRadioSettings; - Map? getDevicesData; - Map? getWANStatusData; - Map? getBackHaulInfoData; - - final result = pollingResult?.data; - if (result != null) { - getNetworkConnectionsData = - (result[JNAPAction.getNetworkConnections] as JNAPSuccess?)?.output; - getNodesWirelessNetworkConnectionsData = - (result[JNAPAction.getNodesWirelessNetworkConnections] - as JNAPSuccess?) - ?.output; - getRadioInfo = (result[JNAPAction.getRadioInfo] as JNAPSuccess?)?.output; - getDevicesData = (result[JNAPAction.getDevices] as JNAPSuccess?)?.output; - getWANStatusData = - (result[JNAPAction.getWANStatus] as JNAPSuccess?)?.output; - getBackHaulInfoData = - (result[JNAPAction.getBackhaulInfo] as JNAPSuccess?)?.output; - guestRadioSettings = - (result[JNAPAction.getGuestRadioSettings] as JNAPSuccess?)?.output; - } - final List connectionData; - if (getNodesWirelessNetworkConnectionsData != null) { - final nodeWirelessConnections = List.from( - getNodesWirelessNetworkConnectionsData['nodeWirelessConnections'] ?? - []); - connectionData = nodeWirelessConnections.fold>([], - (previousValue, element) { - final nodeWirelessConnection = NodeWirelessConnections.fromMap(element); - - previousValue.addAll(nodeWirelessConnection.connections); - return previousValue; - }); - } else { - connectionData = - List.from(getNetworkConnectionsData?['connections'] ?? []) - .map((e) => Layer2Connection.fromMap(e)) - .toList(); - } - - // Radio settings - final radioList = List.from(getRadioInfo?['radios'] ?? []) - .map((e) => RouterRadio.fromMap(e)) - .toList(); - final radioMap = - Map.fromEntries(radioList.map((e) => MapEntry(e.radioID, e))); - - var newState = const DeviceManagerState(); - newState = _getWirelessConnections(newState, connectionData); - // The data process of NetworkConnections MUST be done before building device list - newState = _getDeviceListAndLocations(newState, getDevicesData); - newState = _getWANStatusModel(newState, getWANStatusData); - newState = _getBackhaukInfoData(newState, getBackHaulInfoData); - newState = newState.copyWith(radioInfos: radioMap); - newState = newState.copyWith( - guestRadioSettings: guestRadioSettings == null - ? null - : GuestRadioSettings.fromMap(guestRadioSettings)); - newState = _checkUpstream(newState); - - newState = newState.copyWith( - lastUpdateTime: pollingResult?.lastUpdate, - ); - - return newState; - } - - DeviceManagerState _getWirelessConnections( - DeviceManagerState state, - List? data, - ) { - var connectionsMap = {}; - if (data != null) { - final connections = data; - for (final connectionData in connections) { - final macAddress = connectionData.macAddress; - final wirelessData = connectionData.wireless; - if (wirelessData != null) { - connectionsMap[macAddress] = wirelessData; - } - } - } - return state.copyWith( - wirelessConnections: connectionsMap, - ); - } - - DeviceManagerState _getDeviceListAndLocations( - DeviceManagerState state, - Map? data, - ) { - var allDevices = []; - if (data != null) { - allDevices = List.from( - data['devices'], - ) - .map((e) => LinksysDevice.fromMap(e)) - // .map((e) => e.copyWith(signalDecibels: getWirelessSignalOf(e, state))) - .toList(); - // Sort the device list in order to correctly build the location map later - allDevices.sort((device1, device2) { - if (device1.isAuthority) { - return -1; - } else if (device1.nodeType == null) { - return 1; - } else if (device2.nodeType != null) { - return (device1.nodeType == 'Master') ? -1 : 1; - } else { - return -1; - } - }); - } - var nodes = allDevices.where((device) => device.nodeType != null).toList(); - var externalDevices = - allDevices.where((device) => device.nodeType == null).toList(); - // final masterId = - // nodes.firstWhereOrNull((node) => node.isAuthority)?.deviceID; - - // Collect all the connected devices for nodes - nodes = nodes.fold([], (list, node) { - final connectedDevices = externalDevices.where((device) { - // Make sure the external device is online - if (device.isOnline()) { - // There usually be only one item - final parentDeviceId = device.connections.firstOrNull?.parentDeviceID; - // Count it if this item's parentId is the target node, - // or if its parentId is null and the target node is master - // return ((parentDeviceId == node.deviceID) || - // (parentDeviceId == null && node.deviceID == masterId)); - - // For orphan nodes, don't caculate into any nodes - return parentDeviceId == node.deviceID; - } - return false; - }).toList(); - - return list..add(node.copyWith(connectedDevices: connectedDevices)); - }).toList(); - - // Determine connected Wi-Fi network for each external deivce - final wirelessConnections = state.wirelessConnections; - externalDevices = externalDevices.map((device) { - final wirelessData = wirelessConnections[device.getMacAddress()]; - final isGuestDevice = wirelessData?.isGuest ?? false; - // Get the list of MLO capable radio IDs - final mloList = device.knownInterfaces?.map((e) { - final wirelessData = wirelessConnections[e.macAddress]; - if (wirelessData != null) { - return wirelessData.isMLOCapable == true - ? wirelessData.radioID ?? '' - : ''; - } - return ''; - }).toList() - ?..removeWhere((e) => e.isEmpty); - return device.copyWith( - connectedWifiType: - isGuestDevice ? WifiConnectionType.guest : WifiConnectionType.main, - mloList: mloList, - ); - }).toList(); - - return state.copyWith( - deviceList: [...nodes, ...externalDevices], - ); - } - - DeviceManagerState _getWANStatusModel( - DeviceManagerState state, - Map? data, - ) { - return state.copyWith( - wanStatus: data != null ? RouterWANStatus.fromMap(data) : null, - ); - } - - DeviceManagerState _getBackhaukInfoData( - DeviceManagerState state, - Map? data, - ) { - var newState = state.copyWith( - backhaulInfoData: List.from( - data?['backhaulDevices'] ?? [], - ).map((e) => BackHaulInfoData.fromMap(e)).toList(), - ); - // Update IP address - newState = newState.copyWith( - deviceList: newState.deviceList.map((device) { - final deviceId = device.deviceID; - final backhaulInfo = newState.backhaulInfoData - .firstWhereOrNull((backhaul) => backhaul.deviceUUID == deviceId); - if (backhaulInfo != null && device.isOnline()) { - // Replace the IP in Devices with the one from BackhaulInfo - final updatedConnections = device.connections - .map( - (connection) => connection.copyWith( - ipAddress: backhaulInfo.ipAddress, - ), - ) - .toList(); - final newDevice = device.copyWith( - connections: updatedConnections, - wirelessConnectionInfo: backhaulInfo.wirelessConnectionInfo, - speedMbps: backhaulInfo.speedMbps, - connectionType: backhaulInfo.connectionType, - ); - return newDevice; - } - return device; - }).toList(), - ); - final wireleeConnectionInfo = newState.wirelessConnections; - newState.backhaulInfoData - .where((element) => - element.connectionType == 'Wireless' && - element.wirelessConnectionInfo != null) - .forEach((element) { - final mac = element.wirelessConnectionInfo?.stationBSSID; - final rssi = element.wirelessConnectionInfo?.stationRSSI; - final band = element.wirelessConnectionInfo?.radioID; - final bssid = element.wirelessConnectionInfo?.apBSSID; - if (mac != null && rssi != null) { - wireleeConnectionInfo[mac] = WirelessConnection( - bssid: bssid ?? '', - isGuest: false, - radioID: 'RADIO_${band}z', - band: '${band}z', - signalDecibels: rssi, - ); - } - }); - newState = newState.copyWith(wirelessConnections: wireleeConnectionInfo); - - // update wireless signal for each device - final devices = newState.deviceList - .map((e) => e.copyWith( - signalDecibels: e.wirelessConnectionInfo?.stationRSSI ?? - _getWirelessSignalOf(e, state), - connectedDevices: e.connectedDevices - .map((e) => e.copyWith( - signalDecibels: _getWirelessSignalOf(e, state))) - .toList(), - )) - .toList(); - newState = newState.copyWith(deviceList: devices); - return newState; - } - - DeviceManagerState _checkUpstream( - DeviceManagerState state, - ) { - return state.copyWith( - deviceList: List.from(state.deviceList) - .map((e) => e.isAuthority - ? e - : e.copyWith(upstream: findParent(e.deviceID, state))) - .toList()); + state = const DeviceManagerState(); } // Used in cases where the watched DeviceManager is still empty at very beginning stage @@ -307,20 +32,12 @@ class DeviceManagerNotifier extends Notifier { final wirelessData = wirelessConnections[device.getMacAddress()]; final radioID = wirelessData?.radioID; - /// if connection type is guest just ruturn any one of ssid, because it is all the same + /// if connection type is guest just return any one of ssid, because it is all the same return device.connectedWifiType == WifiConnectionType.guest ? state.guestRadioSettings?.radios.firstOrNull?.guestSSID : state.radioInfos[radioID]?.settings.ssid; } - int? _getWirelessSignalOf(RawDevice device, - [DeviceManagerState? currentState]) { - final wirelessConnections = (currentState ?? state).wirelessConnections; - final wirelessData = wirelessConnections[device.getMacAddress()]; - final signalDecibels = wirelessData?.signalDecibels; - return signalDecibels; - } - String getBandConnectedBy(LinksysDevice device) { final wirelessConnections = state.wirelessConnections; final wirelessData = wirelessConnections[device.getMacAddress()]; @@ -333,7 +50,7 @@ class DeviceManagerNotifier extends Notifier { : ''); } - String? _getBandFromKnownInterfacesOf(RawDevice device) { + String? _getBandFromKnownInterfacesOf(LinksysDevice device) { return device.knownInterfaces ?.firstWhereOrNull( (knownInterface) => knownInterface.interfaceType == 'Wireless') @@ -387,68 +104,50 @@ class DeviceManagerNotifier extends Notifier { required bool isLocation, IconDeviceCategory? icon, }) async { - final routerRepository = ref.read(routerRepositoryProvider); - List properties = [ - RawDeviceProperty(name: 'userDeviceName', value: newName), - if (isLocation) - RawDeviceProperty(name: 'userDeviceLocation', value: newName), - if (icon != null) - RawDeviceProperty(name: 'userDeviceType', value: icon.name), - ]; - final result = await routerRepository.send( - JNAPAction.setDeviceProperties, - data: { - 'deviceID': targetId, - 'propertiesToModify': properties.map((e) => e.toMap()).toList(), - }, - auth: true, + final service = ref.read(deviceManagerServiceProvider); + final properties = await service.updateDeviceNameAndIcon( + targetId: targetId, + newName: newName, + isLocation: isLocation, + icon: icon, ); - await routerRepository.send(JNAPAction.getDevices, - fetchRemote: true, auth: true); - if (result.result == 'OK') { - final newList = state.deviceList.fold([], (list, element) { - if (element.deviceID == targetId) { - list.add(element.copyWith(properties: properties)); - } else { - list.add(element); - } - return list; - }); - state = state.copyWith(deviceList: newList); - } + + // Update local state with the new properties + final newList = state.deviceList.fold([], (list, element) { + if (element.deviceID == targetId) { + list.add(element.copyWith(properties: properties)); + } else { + list.add(element); + } + return list; + }); + state = state.copyWith(deviceList: newList); } - Future deleteDevices({required List deviceIds}) { - final routerRepository = ref.read(routerRepositoryProvider); - return routerRepository.deleteDevices(deviceIds).then((dataResults) { - final idResults = Map.fromIterables(deviceIds, dataResults) - .entries - .map((entry) => MapEntry(entry.key, entry.value.value)); - final idResultsMap = Map.fromEntries(idResults); - idResultsMap.removeWhere((key, value) => value.result != 'OK'); - final completedIds = idResultsMap.keys.toList(); - final newDeviceList = List.from(state.deviceList); - newDeviceList.removeWhere( - (device) => completedIds.contains(device.deviceID), - ); - state = state.copyWith( - deviceList: newDeviceList, - ); - }).then((value) => ref.read(pollingProvider.notifier).forcePolling()); + Future deleteDevices({required List deviceIds}) async { + final service = ref.read(deviceManagerServiceProvider); + final results = await service.deleteDevices(deviceIds); + + // Remove successfully deleted devices from local state + final completedIds = + results.entries.where((e) => e.value).map((e) => e.key).toList(); + final newDeviceList = List.from(state.deviceList); + newDeviceList.removeWhere( + (device) => completedIds.contains(device.deviceID), + ); + state = state.copyWith( + deviceList: newDeviceList, + ); + + // Trigger polling refresh + await ref.read(pollingProvider.notifier).forcePolling(); } Future deauthClient({required String macAddress}) async { - final routerRepository = ref.read(routerRepositoryProvider); - await routerRepository - .send( - JNAPAction.clientDeauth, - data: { - 'macAddress': macAddress, - }..removeWhere((key, value) => value == null), - auth: true, - cacheLevel: CacheLevel.noCache, - fetchRemote: true, - ) - .then((value) => ref.read(pollingProvider.notifier).forcePolling()); + final service = ref.read(deviceManagerServiceProvider); + await service.deauthClient(macAddress); + + // Trigger polling refresh + await ref.read(pollingProvider.notifier).forcePolling(); } } diff --git a/lib/core/jnap/services/dashboard_manager_service.dart b/lib/core/jnap/services/dashboard_manager_service.dart new file mode 100644 index 000000000..3fadca873 --- /dev/null +++ b/lib/core/jnap/services/dashboard_manager_service.dart @@ -0,0 +1,211 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/models/soft_sku_settings.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; + +final dashboardManagerServiceProvider = + Provider((ref) { + return DashboardManagerService(ref.watch(routerRepositoryProvider)); +}); + +/// Service for dashboard management operations. +/// +/// Handles JNAP communication and transforms raw API responses +/// into DashboardManagerState. This isolates JNAP protocol details +/// from the DashboardManagerNotifier. +class DashboardManagerService { + final RouterRepository _routerRepository; + + DashboardManagerService(this._routerRepository); + + // === Data Transformation === + + /// Transforms polling data into DashboardManagerState. + /// + /// [pollingResult] - Raw JNAP transaction data from pollingProvider. + /// Can be null during initial load. + /// + /// Returns: Complete DashboardManagerState with all dashboard information. + /// + /// Behavior: + /// - If [pollingResult] is null, returns empty default state + /// - Processes all available JNAP action results + /// - Skips failed actions gracefully (partial state) + /// - Never throws - always returns valid state + DashboardManagerState transformPollingData( + CoreTransactionData? pollingResult) { + Map? getDeviceInfoData; + Map? getRadioInfoData; + Map? getGuestRadioSettingsData; + Map? getSystemStats; + Map? getEthernetPortConnections; + Map? getLocalTime; + + final result = pollingResult?.data; + if (result != null) { + // Safely extract output only from successful results + JNAPSuccess? getSuccess(JNAPAction action) { + final r = result[action]; + return r is JNAPSuccess ? r : null; + } + + getDeviceInfoData = getSuccess(JNAPAction.getDeviceInfo)?.output; + getRadioInfoData = getSuccess(JNAPAction.getRadioInfo)?.output; + getGuestRadioSettingsData = + getSuccess(JNAPAction.getGuestRadioSettings)?.output; + getSystemStats = getSuccess(JNAPAction.getSystemStats)?.output; + getEthernetPortConnections = + getSuccess(JNAPAction.getEthernetPortConnections)?.output; + getLocalTime = getSuccess(JNAPAction.getLocalTime)?.output; + } + + var newState = const DashboardManagerState(); + if (getDeviceInfoData != null) { + newState = newState.copyWith( + deviceInfo: NodeDeviceInfo.fromJson(getDeviceInfoData)); + } + if (getRadioInfoData != null) { + newState = _getMainRadioList(newState, getRadioInfoData); + } + if (getGuestRadioSettingsData != null) { + newState = _getGuestRadioList(newState, getGuestRadioSettingsData); + } + + if (getSystemStats != null) { + final uptimeSeconds = getSystemStats['uptimeSeconds']; + final cpuLoad = getSystemStats['CPULoad']; + final memoryLoad = getSystemStats['MemoryLoad']; + newState = newState.copyWith( + uptimes: uptimeSeconds, cpuLoad: cpuLoad, memoryLoad: memoryLoad); + } + + if (getEthernetPortConnections != null) { + final lanPortConnections = + List.from(getEthernetPortConnections['lanPortConnections']); + final wanPortConnection = getEthernetPortConnections['wanPortConnection']; + newState = newState.copyWith( + lanConnections: lanPortConnections, wanConnection: wanPortConnection); + } + + String? timeString; + if (getLocalTime != null) { + timeString = getLocalTime['currentTime']; + } + + // Try to parse the time string, fallback to current time if parsing fails + DateTime? parsedTime; + if (timeString != null) { + parsedTime = DateFormat("yyyy-MM-ddThh:mm:ssZ").tryParse(timeString); + } + final localTime = (parsedTime ?? DateTime.now()).millisecondsSinceEpoch; + newState = newState.copyWith(localTime: localTime); + + final softSKUSettings = JNAPTransactionSuccessWrap.getResult( + JNAPAction.getSoftSKUSettings, result ?? {}); + if (softSKUSettings != null) { + final settings = SoftSKUSettings.fromMap(softSKUSettings.output); + newState = newState.copyWith(skuModelNumber: settings.modelNumber); + } + + return newState; + } + + /// Extract main radio list from radio info data. + DashboardManagerState _getMainRadioList( + DashboardManagerState state, Map data) { + final getRadioInfoData = GetRadioInfo.fromMap(data); + return state.copyWith(mainRadios: getRadioInfoData.radios); + } + + /// Extract guest radio list from guest radio settings data. + DashboardManagerState _getGuestRadioList( + DashboardManagerState state, Map data) { + final guestRadioSettings = GuestRadioSettings.fromMap(data); + return state.copyWith( + guestRadios: guestRadioSettings.radios, + isGuestNetworkEnabled: guestRadioSettings.isGuestNetworkEnabled); + } + + // === Router Connectivity === + + /// Checks if the router is accessible and matches expected serial number. + /// + /// [expectedSerialNumber] - The serial number to verify against + /// + /// Returns: NodeDeviceInfo if router is reachable and SN matches + /// + /// Throws: + /// - [SerialNumberMismatchError] if connected router has different SN + /// - [ConnectivityError] if router is unreachable + Future checkRouterIsBack(String expectedSerialNumber) async { + try { + final result = await _routerRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + ); + final nodeDeviceInfo = NodeDeviceInfo.fromJson(result.output); + + if (expectedSerialNumber.isNotEmpty && + expectedSerialNumber != nodeDeviceInfo.serialNumber) { + throw SerialNumberMismatchError( + expected: expectedSerialNumber, + actual: nodeDeviceInfo.serialNumber, + ); + } + + return nodeDeviceInfo; + } on JNAPError catch (e) { + throw _mapJnapError(e); + } on SerialNumberMismatchError { + rethrow; + } catch (e) { + throw ConnectivityError(message: e.toString()); + } + } + + // === Device Info === + + /// Retrieves device info, using cached value if available. + /// + /// [cachedDeviceInfo] - Previously cached device info (from state) + /// + /// Returns: NodeDeviceInfo from cache or fresh API call + /// + /// Throws: [ServiceError] on API failure when cache is unavailable + Future checkDeviceInfo( + NodeDeviceInfo? cachedDeviceInfo) async { + if (cachedDeviceInfo != null) { + return cachedDeviceInfo; + } + + try { + final result = await _routerRepository.send( + JNAPAction.getDeviceInfo, + retries: 0, + timeoutMs: 3000, + ); + return NodeDeviceInfo.fromJson(result.output); + } on JNAPError catch (e) { + throw _mapJnapError(e); + } + } + + /// Maps JNAP errors to ServiceError types + ServiceError _mapJnapError(JNAPError error) { + return switch (error.result) { + '_ErrorUnauthorized' => const UnauthorizedError(), + 'ErrorDeviceNotFound' => const ResourceNotFoundError(), + 'ErrorInvalidInput' => InvalidInputError(message: error.error), + _ => UnexpectedError(originalError: error, message: error.result), + }; + } +} diff --git a/lib/core/jnap/services/device_manager_service.dart b/lib/core/jnap/services/device_manager_service.dart new file mode 100644 index 000000000..37a3c9b0e --- /dev/null +++ b/lib/core/jnap/services/device_manager_service.dart @@ -0,0 +1,457 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/command/base_command.dart'; +import 'package:privacy_gui/core/jnap/extensions/_extensions.dart'; +import 'package:privacy_gui/core/jnap/models/back_haul_info.dart'; +import 'package:privacy_gui/core/jnap/models/device.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/layer2_connection.dart'; +import 'package:privacy_gui/core/jnap/models/node_wireless_connection.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/models/wan_status.dart'; +import 'package:privacy_gui/core/jnap/models/wirless_connection.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/core/utils/icon_device_category.dart'; + +final deviceManagerServiceProvider = Provider((ref) { + return DeviceManagerService(ref.watch(routerRepositoryProvider)); +}); + +/// Service for device management operations. +/// +/// Handles JNAP communication and transforms raw API responses +/// into DeviceManagerState. This isolates JNAP protocol details +/// from the DeviceManagerNotifier. +class DeviceManagerService { + final RouterRepository _routerRepository; + + DeviceManagerService(this._routerRepository); + + // === Data Transformation === + + /// Transforms polling data into DeviceManagerState. + /// + /// [pollingResult] - Raw JNAP transaction data from pollingProvider. + /// Can be null during initial load. + /// + /// Returns: Complete DeviceManagerState with all device information. + /// + /// Behavior: + /// - If [pollingResult] is null, returns empty default state + /// - Processes all available JNAP action results + /// - Skips failed actions gracefully (partial state) + /// - Never throws - always returns valid state + DeviceManagerState transformPollingData(CoreTransactionData? pollingResult) { + Map? getNetworkConnectionsData; + Map? getNodesWirelessNetworkConnectionsData; + Map? getRadioInfo; + Map? guestRadioSettings; + Map? getDevicesData; + Map? getWANStatusData; + Map? getBackHaulInfoData; + + final result = pollingResult?.data; + if (result != null) { + // Safely extract output only from successful results + JNAPSuccess? getSuccess(JNAPAction action) { + final r = result[action]; + return r is JNAPSuccess ? r : null; + } + + getNetworkConnectionsData = + getSuccess(JNAPAction.getNetworkConnections)?.output; + getNodesWirelessNetworkConnectionsData = + getSuccess(JNAPAction.getNodesWirelessNetworkConnections)?.output; + getRadioInfo = getSuccess(JNAPAction.getRadioInfo)?.output; + getDevicesData = getSuccess(JNAPAction.getDevices)?.output; + getWANStatusData = getSuccess(JNAPAction.getWANStatus)?.output; + getBackHaulInfoData = getSuccess(JNAPAction.getBackhaulInfo)?.output; + guestRadioSettings = getSuccess(JNAPAction.getGuestRadioSettings)?.output; + } + + final List connectionData; + if (getNodesWirelessNetworkConnectionsData != null) { + final nodeWirelessConnections = List.from( + getNodesWirelessNetworkConnectionsData['nodeWirelessConnections'] ?? + []); + connectionData = nodeWirelessConnections.fold>([], + (previousValue, element) { + final nodeWirelessConnection = NodeWirelessConnections.fromMap(element); + previousValue.addAll(nodeWirelessConnection.connections); + return previousValue; + }); + } else { + connectionData = + List.from(getNetworkConnectionsData?['connections'] ?? []) + .map((e) => Layer2Connection.fromMap(e)) + .toList(); + } + + // Radio settings + final radioList = List.from(getRadioInfo?['radios'] ?? []) + .map((e) => RouterRadio.fromMap(e)) + .toList(); + final radioMap = + Map.fromEntries(radioList.map((e) => MapEntry(e.radioID, e))); + + var newState = const DeviceManagerState(); + newState = _getWirelessConnections(newState, connectionData); + // The data process of NetworkConnections MUST be done before building device list + newState = _getDeviceListAndLocations(newState, getDevicesData); + newState = _getWANStatusModel(newState, getWANStatusData); + newState = _getBackhaulInfoData(newState, getBackHaulInfoData); + newState = newState.copyWith(radioInfos: radioMap); + newState = newState.copyWith( + guestRadioSettings: guestRadioSettings == null + ? null + : GuestRadioSettings.fromMap(guestRadioSettings)); + newState = _checkUpstream(newState); + + newState = newState.copyWith( + lastUpdateTime: pollingResult?.lastUpdate, + ); + + return newState; + } + + DeviceManagerState _getWirelessConnections( + DeviceManagerState state, + List? data, + ) { + var connectionsMap = {}; + if (data != null) { + final connections = data; + for (final connectionData in connections) { + final macAddress = connectionData.macAddress; + final wirelessData = connectionData.wireless; + if (wirelessData != null) { + connectionsMap[macAddress] = wirelessData; + } + } + } + return state.copyWith( + wirelessConnections: connectionsMap, + ); + } + + DeviceManagerState _getDeviceListAndLocations( + DeviceManagerState state, + Map? data, + ) { + var allDevices = []; + if (data != null) { + allDevices = List.from( + data['devices'], + ).map((e) => LinksysDevice.fromMap(e)).toList(); + // Sort the device list in order to correctly build the location map later + allDevices.sort((device1, device2) { + if (device1.isAuthority) { + return -1; + } else if (device1.nodeType == null) { + return 1; + } else if (device2.nodeType != null) { + return (device1.nodeType == 'Master') ? -1 : 1; + } else { + return -1; + } + }); + } + var nodes = allDevices.where((device) => device.nodeType != null).toList(); + var externalDevices = + allDevices.where((device) => device.nodeType == null).toList(); + + // Collect all the connected devices for nodes + nodes = nodes.fold([], (list, node) { + final connectedDevices = externalDevices.where((device) { + // Make sure the external device is online + if (device.isOnline()) { + // There usually be only one item + final parentDeviceId = device.connections.firstOrNull?.parentDeviceID; + // For orphan nodes, don't calculate into any nodes + return parentDeviceId == node.deviceID; + } + return false; + }).toList(); + + return list..add(node.copyWith(connectedDevices: connectedDevices)); + }).toList(); + + // Determine connected Wi-Fi network for each external device + final wirelessConnections = state.wirelessConnections; + externalDevices = externalDevices.map((device) { + final wirelessData = wirelessConnections[device.getMacAddress()]; + final isGuestDevice = wirelessData?.isGuest ?? false; + // Get the list of MLO capable radio IDs + final mloList = device.knownInterfaces?.map((e) { + final wirelessData = wirelessConnections[e.macAddress]; + if (wirelessData != null) { + return wirelessData.isMLOCapable == true + ? wirelessData.radioID ?? '' + : ''; + } + return ''; + }).toList() + ?..removeWhere((e) => e.isEmpty); + return device.copyWith( + connectedWifiType: + isGuestDevice ? WifiConnectionType.guest : WifiConnectionType.main, + mloList: mloList, + ); + }).toList(); + + return state.copyWith( + deviceList: [...nodes, ...externalDevices], + ); + } + + DeviceManagerState _getWANStatusModel( + DeviceManagerState state, + Map? data, + ) { + return state.copyWith( + wanStatus: data != null ? RouterWANStatus.fromMap(data) : null, + ); + } + + DeviceManagerState _getBackhaulInfoData( + DeviceManagerState state, + Map? data, + ) { + var newState = state.copyWith( + backhaulInfoData: List.from( + data?['backhaulDevices'] ?? [], + ).map((e) => BackHaulInfoData.fromMap(e)).toList(), + ); + // Update IP address + newState = newState.copyWith( + deviceList: newState.deviceList.map((device) { + final deviceId = device.deviceID; + final backhaulInfo = newState.backhaulInfoData + .firstWhereOrNull((backhaul) => backhaul.deviceUUID == deviceId); + if (backhaulInfo != null && device.isOnline()) { + // Replace the IP in Devices with the one from BackhaulInfo + final updatedConnections = device.connections + .map( + (connection) => connection.copyWith( + ipAddress: backhaulInfo.ipAddress, + ), + ) + .toList(); + final newDevice = device.copyWith( + connections: updatedConnections, + wirelessConnectionInfo: backhaulInfo.wirelessConnectionInfo, + speedMbps: backhaulInfo.speedMbps, + connectionType: backhaulInfo.connectionType, + ); + return newDevice; + } + return device; + }).toList(), + ); + final wirelessConnectionInfo = newState.wirelessConnections; + newState.backhaulInfoData + .where((element) => + element.connectionType == 'Wireless' && + element.wirelessConnectionInfo != null) + .forEach((element) { + final mac = element.wirelessConnectionInfo?.stationBSSID; + final rssi = element.wirelessConnectionInfo?.stationRSSI; + final band = element.wirelessConnectionInfo?.radioID; + final bssid = element.wirelessConnectionInfo?.apBSSID; + if (mac != null && rssi != null) { + wirelessConnectionInfo[mac] = WirelessConnection( + bssid: bssid ?? '', + isGuest: false, + radioID: 'RADIO_${band}z', + band: '${band}z', + signalDecibels: rssi, + ); + } + }); + newState = newState.copyWith(wirelessConnections: wirelessConnectionInfo); + + // update wireless signal for each device + final devices = newState.deviceList + .map((e) => e.copyWith( + signalDecibels: e.wirelessConnectionInfo?.stationRSSI ?? + _getWirelessSignalOf(e, newState), + connectedDevices: e.connectedDevices + .map((e) => e.copyWith( + signalDecibels: _getWirelessSignalOf(e, newState))) + .toList(), + )) + .toList(); + newState = newState.copyWith(deviceList: devices); + return newState; + } + + DeviceManagerState _checkUpstream(DeviceManagerState state) { + return state.copyWith( + deviceList: List.from(state.deviceList) + .map((e) => e.isAuthority + ? e + : e.copyWith(upstream: _findParent(e.deviceID, state))) + .toList()); + } + + int? _getWirelessSignalOf(RawDevice device, DeviceManagerState currentState) { + final wirelessConnections = currentState.wirelessConnections; + final wirelessData = wirelessConnections[device.getMacAddress()]; + final signalDecibels = wirelessData?.signalDecibels; + return signalDecibels; + } + + LinksysDevice? _findParent(String deviceID, DeviceManagerState currentState) { + final master = currentState.masterDevice; + final device = currentState.deviceList + .firstWhereOrNull((element) => element.deviceID == deviceID); + if (device == null) { + return null; + } + if (!device.isOnline()) { + return null; + } + String? parentIpAddr; + + // Check connections from backhaul info data. + for (var element in device.connections) { + for (var backhaul in currentState.backhaulInfoData) { + if (backhaul.ipAddress == element.ipAddress) { + parentIpAddr = backhaul.parentIPAddress; + break; + } + } + } + // + if (parentIpAddr != null) { + return currentState.deviceList.firstWhereOrNull((element) => + element.connections.firstWhereOrNull( + (element) => element.ipAddress == parentIpAddr) != + null) ?? + master; + } + // + // There usually be only one item + final parentDeviceId = device.connections.firstOrNull?.parentDeviceID; + // Count it if this item's parentId is the target node, + // or if its parentId is null and the target node is master + return currentState.deviceList.firstWhereOrNull( + (element) => parentDeviceId == element.deviceID) ?? + (device.nodeType != null ? master : null); + } + + // === Write Operations === + + /// Updates device name and/or icon. + /// + /// [targetId] - Device ID to update + /// [newName] - New display name for the device + /// [isLocation] - If true, also updates userDeviceLocation + /// [icon] - Optional icon category to set + /// + /// Returns: List of updated device properties + /// + /// Throws: [ServiceError] on JNAP failure + Future> updateDeviceNameAndIcon({ + required String targetId, + required String newName, + required bool isLocation, + IconDeviceCategory? icon, + }) async { + List properties = [ + RawDeviceProperty(name: 'userDeviceName', value: newName), + if (isLocation) + RawDeviceProperty(name: 'userDeviceLocation', value: newName), + if (icon != null) + RawDeviceProperty(name: 'userDeviceType', value: icon.name), + ]; + + try { + final result = await _routerRepository.send( + JNAPAction.setDeviceProperties, + data: { + 'deviceID': targetId, + 'propertiesToModify': properties.map((e) => e.toMap()).toList(), + }, + auth: true, + ); + + // Refresh device list after update + await _routerRepository.send( + JNAPAction.getDevices, + fetchRemote: true, + auth: true, + ); + + if (result.result != 'OK') { + throw UnexpectedError( + originalError: result, + message: 'Failed to update device properties: ${result.result}', + ); + } + + return properties; + } on JNAPError catch (e) { + throw _mapJnapError(e); + } + } + + /// Deletes devices from the network. + /// + /// [deviceIds] - List of device IDs to delete + /// + /// Returns: Map of deviceId → success/failure status + /// + /// Behavior: + /// - Empty list returns empty map immediately (no-op) + /// - Processes deletions in bulk + /// - Partial failures are reflected in return map + Future> deleteDevices(List deviceIds) async { + if (deviceIds.isEmpty) { + return {}; + } + + final dataResults = await _routerRepository.deleteDevices(deviceIds); + final idResults = Map.fromIterables(deviceIds, dataResults) + .entries + .map((entry) => MapEntry(entry.key, entry.value.value.result == 'OK')); + return Map.fromEntries(idResults); + } + + /// Deauthenticates a client device. + /// + /// [macAddress] - MAC address of device to disconnect + /// + /// Throws: [ServiceError] on JNAP failure + Future deauthClient(String macAddress) async { + try { + await _routerRepository.send( + JNAPAction.clientDeauth, + data: { + 'macAddress': macAddress, + }..removeWhere((key, value) => value == null), + auth: true, + cacheLevel: CacheLevel.noCache, + fetchRemote: true, + ); + } on JNAPError catch (e) { + throw _mapJnapError(e); + } + } + + /// Maps JNAP errors to ServiceError types + ServiceError _mapJnapError(JNAPError error) { + return switch (error.result) { + '_ErrorUnauthorized' => const UnauthorizedError(), + 'ErrorDeviceNotFound' => const ResourceNotFoundError(), + 'ErrorInvalidInput' => InvalidInputError(message: error.error), + _ => UnexpectedError(originalError: error, message: error.result), + }; + } +} diff --git a/lib/page/dashboard/providers/dashboard_home_provider.dart b/lib/page/dashboard/providers/dashboard_home_provider.dart index 5c4e4ab54..eb3cf5785 100644 --- a/lib/page/dashboard/providers/dashboard_home_provider.dart +++ b/lib/page/dashboard/providers/dashboard_home_provider.dart @@ -1,16 +1,9 @@ -import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/jnap/models/radio_info.dart'; import 'package:privacy_gui/core/jnap/providers/dashboard_manager_provider.dart'; -import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; import 'package:privacy_gui/core/jnap/providers/device_manager_provider.dart'; -import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; -import 'package:privacy_gui/core/utils/devices.dart'; -import 'package:privacy_gui/core/utils/icon_rules.dart'; -import 'package:privacy_gui/core/utils/nodes.dart'; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/dashboard/services/dashboard_home_service.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; -import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; final dashboardHomeProvider = NotifierProvider( @@ -22,77 +15,16 @@ class DashboardHomeNotifier extends Notifier { DashboardHomeState build() { final dashboardManagerState = ref.watch(dashboardManagerProvider); final deviceManagerState = ref.watch(deviceManagerProvider); - final healthCheckState = ref.watch(healthCheckProvider); - return createState( - dashboardManagerState, deviceManagerState, healthCheckState); - } - - DashboardHomeState createState( - DashboardManagerState dashboardManagerState, - DeviceManagerState deviceManagerState, - HealthCheckState healthCheckState, - ) { - var newState = const DashboardHomeState(); - // Get WiFi list - final wifiList = dashboardManagerState.mainRadios - .groupFoldBy>((element) => element.band, - (previous, element) => [...(previous ?? []), element]) - .entries - .map((e) => DashboardWiFiItem.fromMainRadios( - e.value, - deviceManagerState.mainWifiDevices.where((device) { - final deviceBand = ref - .read(deviceManagerProvider.notifier) - .getBandConnectedBy(device); - return device.nodeType == null && - device.isOnline() && - e.value.any((element) => element.band == deviceBand); - }).length)) - .toList(); - if (dashboardManagerState.guestRadios.isNotEmpty) { - wifiList.add(DashboardWiFiItem.fromGuestRadios( - dashboardManagerState.guestRadios, - deviceManagerState.guestWifiDevices - .where((device) => device.isOnline()) - .length) - .copyWith(isEnabled: dashboardManagerState.isGuestNetworkEnabled)); - } - - // Get Node list - final isAnyNodesOffline = - deviceManagerState.nodeDevices.any((element) => !element.isOnline()); - - // Get WAN type - final wanType = deviceManagerState.wanStatus?.wanConnection?.wanType; - final detectedWANType = deviceManagerState.wanStatus?.detectedWANType; - - // If is first polling - final isFirstPolling = deviceManagerState.lastUpdateTime == 0; - // Get master node icon - final sortedDeviceList = ref.read(deviceManagerProvider).deviceList; - final masterIcon = routerIconTestByModel( - modelNumber: sortedDeviceList.firstOrNull?.model.modelNumber ?? '', - hardwareVersion: sortedDeviceList.firstOrNull?.model.hardwareVersion, + // Watch healthCheckProvider to maintain reactivity (even though we don't use it directly) + ref.watch(healthCheckProvider); + + final service = ref.read(dashboardHomeServiceProvider); + return service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: (device) => + ref.read(deviceManagerProvider.notifier).getBandConnectedBy(device), + deviceList: ref.read(deviceManagerProvider).deviceList, ); - - final deviceInfo = dashboardManagerState.deviceInfo; - final horizontalPortLayout = isHorizontalPorts( - modelNumber: deviceInfo?.modelNumber ?? '', - hardwareVersion: deviceInfo?.hardwareVersion ?? '1'); - - newState = newState.copyWith( - wifis: wifiList, - uptime: () => dashboardManagerState.uptimes, - wanPortConnection: () => dashboardManagerState.wanConnection, - lanPortConnections: dashboardManagerState.lanConnections, - isFirstPolling: isFirstPolling, - masterIcon: masterIcon, - isAnyNodesOffline: isAnyNodesOffline, - isHorizontalLayout: horizontalPortLayout, - wanType: () => wanType, - detectedWANType: () => detectedWANType, - ); - - return newState; } } diff --git a/lib/page/dashboard/providers/dashboard_home_state.dart b/lib/page/dashboard/providers/dashboard_home_state.dart index d7b50d4fb..70c1a6b0e 100644 --- a/lib/page/dashboard/providers/dashboard_home_state.dart +++ b/lib/page/dashboard/providers/dashboard_home_state.dart @@ -4,23 +4,20 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; -import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; -import 'package:privacy_gui/core/jnap/models/radio_info.dart'; - -class DashboardSpeedItem extends Equatable { +class DashboardSpeedUIModel extends Equatable { final String unit; final String value; - const DashboardSpeedItem({ + const DashboardSpeedUIModel({ required this.unit, required this.value, }); - DashboardSpeedItem copyWith({ + DashboardSpeedUIModel copyWith({ String? unit, String? value, }) { - return DashboardSpeedItem( + return DashboardSpeedUIModel( unit: unit ?? this.unit, value: value ?? this.value, ); @@ -33,8 +30,8 @@ class DashboardSpeedItem extends Equatable { }; } - factory DashboardSpeedItem.fromMap(Map map) { - return DashboardSpeedItem( + factory DashboardSpeedUIModel.fromMap(Map map) { + return DashboardSpeedUIModel( unit: map['unit'] ?? '', value: map['value'] ?? '', ); @@ -42,17 +39,17 @@ class DashboardSpeedItem extends Equatable { String toJson() => json.encode(toMap()); - factory DashboardSpeedItem.fromJson(String source) => - DashboardSpeedItem.fromMap(json.decode(source)); + factory DashboardSpeedUIModel.fromJson(String source) => + DashboardSpeedUIModel.fromMap(json.decode(source)); @override - String toString() => 'DasboardSpeedItem(unit: $unit, value: $value)'; + String toString() => 'DashboardSpeedUIModel(unit: $unit, value: $value)'; @override List get props => [unit, value]; } -class DashboardWiFiItem extends Equatable { +class DashboardWiFiUIModel extends Equatable { final String ssid; final String password; final List radios; @@ -60,7 +57,7 @@ class DashboardWiFiItem extends Equatable { final bool isEnabled; final int numOfConnectedDevices; - const DashboardWiFiItem({ + const DashboardWiFiUIModel({ required this.ssid, required this.password, required this.radios, @@ -69,33 +66,7 @@ class DashboardWiFiItem extends Equatable { required this.numOfConnectedDevices, }); - factory DashboardWiFiItem.fromMainRadios( - List radios, int connectedDevices) { - final radio = radios.first; - return DashboardWiFiItem( - ssid: radio.settings.ssid, - password: radio.settings.wpaPersonalSettings?.passphrase ?? '', - radios: radios.map((e) => e.radioID).toList(), - isGuest: false, - isEnabled: radio.settings.isEnabled, - numOfConnectedDevices: connectedDevices, - ); - } - - factory DashboardWiFiItem.fromGuestRadios( - List radios, int connectedDevices) { - final radio = radios.first; - return DashboardWiFiItem( - ssid: radio.guestSSID, - password: radio.guestWPAPassphrase ?? '', - radios: radios.map((e) => e.radioID).toList(), - isGuest: true, - isEnabled: radio.isEnabled, - numOfConnectedDevices: connectedDevices, - ); - } - - DashboardWiFiItem copyWith({ + DashboardWiFiUIModel copyWith({ String? ssid, String? password, List? radios, @@ -103,7 +74,7 @@ class DashboardWiFiItem extends Equatable { bool? isEnabled, int? numOfConnectedDevices, }) { - return DashboardWiFiItem( + return DashboardWiFiUIModel( ssid: ssid ?? this.ssid, password: password ?? this.password, radios: radios ?? this.radios, @@ -125,8 +96,8 @@ class DashboardWiFiItem extends Equatable { }; } - factory DashboardWiFiItem.fromMap(Map map) { - return DashboardWiFiItem( + factory DashboardWiFiUIModel.fromMap(Map map) { + return DashboardWiFiUIModel( ssid: map['ssid'] as String, password: map['password'] as String, radios: List.from(map['radios']), @@ -138,8 +109,8 @@ class DashboardWiFiItem extends Equatable { String toJson() => json.encode(toMap()); - factory DashboardWiFiItem.fromJson(String source) => - DashboardWiFiItem.fromMap(json.decode(source) as Map); + factory DashboardWiFiUIModel.fromJson(String source) => + DashboardWiFiUIModel.fromMap(json.decode(source) as Map); @override bool get stringify => true; @@ -165,7 +136,7 @@ class DashboardHomeState extends Equatable { final int? uptime; final String? wanPortConnection; final List lanPortConnections; - final List wifis; + final List wifis; final String? wanType; final String? detectedWANType; @@ -206,8 +177,8 @@ class DashboardHomeState extends Equatable { uptime: map['uptime']?.toInt(), wanPortConnection: map['wanPortConnection'], lanPortConnections: List.from(map['lanPortConnections']), - wifis: List.from( - map['wifis']?.map((x) => DashboardWiFiItem.fromMap(x))), + wifis: List.from( + map['wifis']?.map((x) => DashboardWiFiUIModel.fromMap(x))), wanType: map['wanType'], detectedWANType: map['detectedWANType'], ); @@ -245,7 +216,7 @@ class DashboardHomeState extends Equatable { ValueGetter? uptime, ValueGetter? wanPortConnection, List? lanPortConnections, - List? wifis, + List? wifis, ValueGetter? wanType, ValueGetter? detectedWANType, }) { diff --git a/lib/page/dashboard/services/dashboard_home_service.dart b/lib/page/dashboard/services/dashboard_home_service.dart new file mode 100644 index 000000000..ad3237068 --- /dev/null +++ b/lib/page/dashboard/services/dashboard_home_service.dart @@ -0,0 +1,164 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/core/utils/icon_rules.dart'; +import 'package:privacy_gui/core/utils/nodes.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; + +/// Riverpod provider for DashboardHomeService +final dashboardHomeServiceProvider = Provider((ref) { + return const DashboardHomeService(); +}); + +/// Stateless service for dashboard home state transformation +/// +/// Encapsulates JNAP model transformations, separating data layer +/// concerns from state management (DashboardHomeNotifier). +class DashboardHomeService { + const DashboardHomeService(); + + /// Transforms JNAP-layer state data into a complete [DashboardHomeState]. + /// + /// This method orchestrates all the data transformation logic required + /// to build the UI state for the dashboard home view. + DashboardHomeState buildDashboardHomeState({ + required DashboardManagerState dashboardManagerState, + required DeviceManagerState deviceManagerState, + required String Function(LinksysDevice device) getBandForDevice, + required List deviceList, + }) { + // Build WiFi list + final wifiList = _buildMainWiFiItems( + mainRadios: dashboardManagerState.mainRadios, + mainWifiDevices: deviceManagerState.mainWifiDevices, + getBandForDevice: getBandForDevice, + ); + + // Add guest WiFi if exists + final guestWifi = _buildGuestWiFiItem( + guestRadios: dashboardManagerState.guestRadios, + guestWifiDevices: deviceManagerState.guestWifiDevices, + isGuestNetworkEnabled: dashboardManagerState.isGuestNetworkEnabled, + ); + if (guestWifi != null) { + wifiList.add(guestWifi); + } + + // Determine node offline status + final isAnyNodesOffline = + deviceManagerState.nodeDevices.any((element) => !element.isOnline()); + + // Get WAN type + final wanType = deviceManagerState.wanStatus?.wanConnection?.wanType; + final detectedWANType = deviceManagerState.wanStatus?.detectedWANType; + + // Determine if first polling + final isFirstPolling = deviceManagerState.lastUpdateTime == 0; + + // Get master node icon + final masterIcon = routerIconTestByModel( + modelNumber: deviceList.firstOrNull?.model.modelNumber ?? '', + hardwareVersion: deviceList.firstOrNull?.model.hardwareVersion, + ); + + // Determine port layout + final deviceInfo = dashboardManagerState.deviceInfo; + final horizontalPortLayout = isHorizontalPorts( + modelNumber: deviceInfo?.modelNumber ?? '', + hardwareVersion: deviceInfo?.hardwareVersion ?? '1', + ); + + return DashboardHomeState( + wifis: wifiList, + uptime: dashboardManagerState.uptimes, + wanPortConnection: dashboardManagerState.wanConnection, + lanPortConnections: dashboardManagerState.lanConnections, + isFirstPolling: isFirstPolling, + masterIcon: masterIcon, + isAnyNodesOffline: isAnyNodesOffline, + isHorizontalLayout: horizontalPortLayout, + wanType: wanType, + detectedWANType: detectedWANType, + ); + } + + /// Builds main WiFi items grouped by band. + List _buildMainWiFiItems({ + required List mainRadios, + required List mainWifiDevices, + required String Function(LinksysDevice) getBandForDevice, + }) { + return mainRadios + .groupFoldBy>( + (element) => element.band, + (previous, element) => [...(previous ?? []), element], + ) + .entries + .map((e) { + final connectedDevices = mainWifiDevices.where((device) { + final deviceBand = getBandForDevice(device); + return device.nodeType == null && + device.isOnline() && + e.value.any((element) => element.band == deviceBand); + }).length; + return _createWiFiItemFromMainRadios(e.value, connectedDevices); + }).toList(); + } + + /// Builds guest WiFi item if guest radios exist. + DashboardWiFiUIModel? _buildGuestWiFiItem({ + required List guestRadios, + required List guestWifiDevices, + required bool isGuestNetworkEnabled, + }) { + if (guestRadios.isEmpty) { + return null; + } + + final connectedDevices = + guestWifiDevices.where((device) => device.isOnline()).length; + + return _createWiFiItemFromGuestRadios(guestRadios, connectedDevices) + .copyWith(isEnabled: isGuestNetworkEnabled); + } + + /// Creates a [DashboardWiFiUIModel] from main radio list. + /// + /// This method replaces the `DashboardWiFiUIModel.fromMainRadios()` factory method. + DashboardWiFiUIModel _createWiFiItemFromMainRadios( + List radios, + int connectedDevices, + ) { + final radio = radios.first; + return DashboardWiFiUIModel( + ssid: radio.settings.ssid, + password: radio.settings.wpaPersonalSettings?.passphrase ?? '', + radios: radios.map((e) => e.radioID).toList(), + isGuest: false, + isEnabled: radio.settings.isEnabled, + numOfConnectedDevices: connectedDevices, + ); + } + + /// Creates a [DashboardWiFiUIModel] from guest radio list. + /// + /// This method replaces the `DashboardWiFiUIModel.fromGuestRadios()` factory method. + DashboardWiFiUIModel _createWiFiItemFromGuestRadios( + List radios, + int connectedDevices, + ) { + final radio = radios.first; + return DashboardWiFiUIModel( + ssid: radio.guestSSID, + password: radio.guestWPAPassphrase ?? '', + radios: radios.map((e) => e.radioID).toList(), + isGuest: true, + isEnabled: radio.isEnabled, + numOfConnectedDevices: connectedDevices, + ); + } +} diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index 4e1e9620c..d6ed76f5c 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -104,7 +104,7 @@ class _DashboardWiFiGridState extends ConsumerState { } class WiFiCard extends ConsumerStatefulWidget { - final DashboardWiFiItem item; + final DashboardWiFiUIModel item; final int index; final bool canBeDisabled; final bool tooltipVisible; diff --git a/specs/001-device-manager-service-extraction/checklists/implementation.md b/specs/001-device-manager-service-extraction/checklists/implementation.md new file mode 100644 index 000000000..e7a9302c3 --- /dev/null +++ b/specs/001-device-manager-service-extraction/checklists/implementation.md @@ -0,0 +1,96 @@ +# Implementation Checklist: Device Manager Service Extraction + +**Purpose**: Validate implementation completeness and architecture compliance for service extraction +**Created**: 2025-12-28 +**Feature**: [spec.md](../spec.md) | [plan.md](../plan.md) + +## Architecture Compliance + +- [x] CHK001 `DeviceManagerService` created at `lib/core/jnap/services/device_manager_service.dart` +- [x] CHK002 `deviceManagerServiceProvider` defined as `Provider` +- [x] CHK003 Service constructor accepts `RouterRepository` as dependency +- [x] CHK004 `DeviceManagerNotifier` imports `deviceManagerServiceProvider` from service file +- [x] CHK005 Provider file has ZERO imports from `core/jnap/models/` +- [x] CHK006 Provider file has ZERO imports from `core/jnap/result/` +- [x] CHK007 Provider file has ZERO imports from `core/jnap/actions/` + +## Service Methods + +- [x] CHK008 `transformPollingData(CoreTransactionData?)` method implemented +- [x] CHK009 `transformPollingData` returns empty/default state when input is null +- [x] CHK010 `updateDeviceNameAndIcon()` method implemented with correct signature +- [x] CHK011 `deleteDevices(List)` method implemented +- [x] CHK012 `deleteDevices` returns empty map immediately when input list is empty +- [x] CHK013 `deauthClient(String macAddress)` method implemented + +## Provider Delegation + +- [x] CHK014 `DeviceManagerNotifier.build()` uses `ref.read(deviceManagerServiceProvider)` +- [x] CHK015 `build()` delegates to `service.transformPollingData()` +- [x] CHK016 `updateDeviceNameAndIcon()` delegates to service +- [x] CHK017 `deleteDevices()` delegates to service +- [x] CHK018 `deauthClient()` delegates to service + +## State Query Methods (Stay in Notifier) + +- [x] CHK019 `isEmptyState()` remains in `DeviceManagerNotifier` +- [x] CHK020 `getSsidConnectedBy()` remains in `DeviceManagerNotifier` +- [x] CHK021 `getBandConnectedBy()` remains in `DeviceManagerNotifier` +- [x] CHK022 `findParent()` remains in `DeviceManagerNotifier` + +## Error Handling + +- [x] CHK023 Service maps JNAP errors to `ServiceError` types +- [x] CHK024 `updateDeviceNameAndIcon` throws `ServiceError` on JNAP failure +- [x] CHK025 `deleteDevices` returns partial success map on partial failure +- [x] CHK026 `deauthClient` throws `ServiceError` on JNAP failure + +## Test Coverage + +- [x] CHK027 Service test file created at `test/core/jnap/services/device_manager_service_test.dart` +- [x] CHK028 Provider test file created at `test/core/jnap/providers/device_manager_provider_test.dart` +- [x] CHK029 Test data builder created at `test/mocks/test_data/device_manager_test_data.dart` +- [x] CHK030 Service tests cover `transformPollingData` with null input +- [x] CHK031 Service tests cover `transformPollingData` with valid data +- [x] CHK032 Service tests cover `updateDeviceNameAndIcon` success case +- [x] CHK033 Service tests cover `updateDeviceNameAndIcon` error case +- [x] CHK034 Service tests cover `deleteDevices` with empty list +- [x] CHK035 Service tests cover `deleteDevices` with valid IDs +- [x] CHK036 Service tests cover `deauthClient` success case +- [x] CHK037 Provider tests verify delegation to service +- [x] CHK038 Service test coverage ≥ 90% +- [x] CHK039 Provider test coverage ≥ 85% + +## Regression Verification + +- [x] CHK040 All existing tests pass (`./run_tests.sh`) +- [ ] CHK041 Device list displays correctly in UI +- [ ] CHK042 Device name/icon updates work +- [ ] CHK043 Device deletion works +- [ ] CHK044 Client deauthentication works + +## Verification Commands + +```bash +# Check provider has no JNAP imports (should output nothing) +grep -E "import.*jnap/(models|result|actions)" lib/core/jnap/providers/device_manager_provider.dart + +# Check service has JNAP imports (should output multiple lines) +grep -E "import.*jnap/models" lib/core/jnap/services/device_manager_service.dart + +# Run service tests +flutter test test/core/jnap/services/device_manager_service_test.dart + +# Run provider tests +flutter test test/core/jnap/providers/device_manager_provider_test.dart + +# Run full test suite +./run_tests.sh +``` + +## Notes + +- Check items off as completed: `[x]` +- CHK005-CHK007 are the critical success criteria for architecture compliance +- CHK019-CHK022 ensure helper methods stay in notifier (they query cached state, not JNAP) +- CHK038-CHK039 must be verified with coverage tools before marking complete diff --git a/specs/001-device-manager-service-extraction/checklists/requirements.md b/specs/001-device-manager-service-extraction/checklists/requirements.md new file mode 100644 index 000000000..de9aa5627 --- /dev/null +++ b/specs/001-device-manager-service-extraction/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Device Manager Service Extraction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All checklist items pass validation +- Specification is ready for `/speckit.clarify` or `/speckit.plan` +- The spec correctly identifies that helper methods querying cached state should remain in the notifier (FR-009) +- Service location in `lib/core/jnap/services/` is appropriate for infrastructure code diff --git a/specs/001-device-manager-service-extraction/contracts/device_manager_service_contract.md b/specs/001-device-manager-service-extraction/contracts/device_manager_service_contract.md new file mode 100644 index 000000000..d4327cdd2 --- /dev/null +++ b/specs/001-device-manager-service-extraction/contracts/device_manager_service_contract.md @@ -0,0 +1,211 @@ +# Contract: DeviceManagerService + +**Feature**: 001-device-manager-service-extraction +**Date**: 2025-12-28 +**Location**: `lib/core/jnap/services/device_manager_service.dart` + +## Overview + +`DeviceManagerService` encapsulates all JNAP communication and data transformation logic for device management. It receives raw JNAP responses and returns `DeviceManagerState`. + +## Provider Definition + +```dart +final deviceManagerServiceProvider = Provider((ref) { + return DeviceManagerService(ref.watch(routerRepositoryProvider)); +}); +``` + +## Class Contract + +```dart +/// Service for device management operations. +/// +/// Handles JNAP communication and transforms raw API responses +/// into DeviceManagerState. This isolates JNAP protocol details +/// from the DeviceManagerNotifier. +class DeviceManagerService { + final RouterRepository _routerRepository; + + DeviceManagerService(this._routerRepository); + + // === Data Transformation === + + /// Transforms polling data into DeviceManagerState. + /// + /// [pollingResult] - Raw JNAP transaction data from pollingProvider. + /// Can be null during initial load. + /// + /// Returns: Complete DeviceManagerState with all device information. + /// + /// Behavior: + /// - If [pollingResult] is null, returns empty default state + /// - Processes all available JNAP action results + /// - Skips failed actions gracefully (partial state) + /// - Never throws - always returns valid state + DeviceManagerState transformPollingData(CoreTransactionData? pollingResult); + + // === Write Operations === + + /// Updates device name and/or icon. + /// + /// [targetId] - Device ID to update + /// [newName] - New display name for the device + /// [isLocation] - If true, also updates userDeviceLocation + /// [icon] - Optional icon category to set + /// + /// Returns: List of updated device properties + /// + /// Throws: [ServiceError] on JNAP failure + Future> updateDeviceNameAndIcon({ + required String targetId, + required String newName, + required bool isLocation, + IconDeviceCategory? icon, + }); + + /// Deletes devices from the network. + /// + /// [deviceIds] - List of device IDs to delete + /// + /// Returns: Map of deviceId → success/failure status + /// + /// Behavior: + /// - Empty list returns empty map immediately (no-op) + /// - Processes deletions in bulk + /// - Partial failures are reflected in return map + /// + /// Throws: [ServiceError] only on complete failure + Future> deleteDevices(List deviceIds); + + /// Deauthenticates a client device. + /// + /// [macAddress] - MAC address of device to disconnect + /// + /// Throws: [ServiceError] on JNAP failure + Future deauthClient(String macAddress); +} +``` + +## Method Details + +### transformPollingData + +**Input**: `CoreTransactionData?` +```dart +// CoreTransactionData contains: +final Map? data; +final int? lastUpdate; +``` + +**Output**: `DeviceManagerState` + +**JNAP Actions Consumed**: +| Action | Field Updated | +|--------|--------------| +| `getNetworkConnections` | wirelessConnections | +| `getNodesWirelessNetworkConnections` | wirelessConnections (mesh) | +| `getRadioInfo` | radioInfos | +| `getGuestRadioSettings` | guestRadioSettings | +| `getDevices` | deviceList | +| `getWANStatus` | wanStatus | +| `getBackhaulInfo` | backhaulInfoData | + +**Processing Order** (important): +1. Extract wireless connections (needed for device processing) +2. Build device list with wireless info +3. Process WAN status +4. Process backhaul info (updates device IPs and signal) +5. Check upstream relationships + +### updateDeviceNameAndIcon + +**JNAP Actions**: +1. `setDeviceProperties` - Set name/location/icon +2. `getDevices` - Refresh device list after update + +**Error Mapping**: +```dart +// JNAP errors → ServiceError +'OK' → success +_ → UnexpectedError(originalError: jnapError) +``` + +### deleteDevices + +**JNAP Actions**: +- Uses `RouterRepository.deleteDevices()` for bulk operation + +**Return Value**: +```dart +{ + 'device-id-1': true, // deleted successfully + 'device-id-2': false, // deletion failed +} +``` + +### deauthClient + +**JNAP Actions**: +- `clientDeauth` with `macAddress` parameter + +## Usage Example + +```dart +// In DeviceManagerNotifier +class DeviceManagerNotifier extends Notifier { + @override + DeviceManagerState build() { + final coreTransactionData = ref.watch(pollingProvider).value; + final service = ref.read(deviceManagerServiceProvider); + return service.transformPollingData(coreTransactionData); + } + + Future updateDeviceNameAndIcon({ + required String targetId, + required String newName, + required bool isLocation, + IconDeviceCategory? icon, + }) async { + final service = ref.read(deviceManagerServiceProvider); + try { + final updatedProps = await service.updateDeviceNameAndIcon( + targetId: targetId, + newName: newName, + isLocation: isLocation, + icon: icon, + ); + // Update local state + _updateDeviceInState(targetId, updatedProps); + } on ServiceError catch (e) { + // Handle error appropriately + logger.e('Failed to update device', error: e); + rethrow; + } + } +} +``` + +## Dependencies + +**Required Imports** (Service only): +```dart +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/command/base_command.dart'; +import 'package:privacy_gui/core/jnap/models/back_haul_info.dart'; +import 'package:privacy_gui/core/jnap/models/device.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/layer2_connection.dart'; +import 'package:privacy_gui/core/jnap/models/node_wireless_connection.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/models/wan_status.dart'; +import 'package:privacy_gui/core/jnap/models/wirless_connection.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +``` + +**Prohibited Imports** (Notifier): +```dart +// ❌ None of the above should appear in device_manager_provider.dart +``` diff --git a/specs/001-device-manager-service-extraction/data-model.md b/specs/001-device-manager-service-extraction/data-model.md new file mode 100644 index 000000000..d5a487d56 --- /dev/null +++ b/specs/001-device-manager-service-extraction/data-model.md @@ -0,0 +1,141 @@ +# Data Model: Device Manager Service Extraction + +**Feature**: 001-device-manager-service-extraction +**Date**: 2025-12-28 + +## Overview + +This feature extracts JNAP logic from `DeviceManagerNotifier` to `DeviceManagerService`. No new data models are created - existing models are preserved. + +## Existing Entities (No Changes) + +### DeviceManagerState + +**Location**: `lib/core/jnap/providers/device_manager_state.dart` +**Status**: UNCHANGED - continues to be the public API + +```dart +class DeviceManagerState extends Equatable { + final Map wirelessConnections; + final Map radioInfos; + final GuestRadioSettings? guestRadioSettings; + final List deviceList; + final RouterWANStatus? wanStatus; + final List backhaulInfoData; + final int lastUpdateTime; + + // Computed properties + List get nodeDevices; + List get externalDevices; + List get mainWifiDevices; + List get guestWifiDevices; + LinksysDevice get masterDevice; + List get slaveDevices; +} +``` + +### LinksysDevice + +**Location**: `lib/core/jnap/providers/device_manager_state.dart` +**Status**: UNCHANGED + +```dart +class LinksysDevice extends RawDevice { + final List connectedDevices; + final WifiConnectionType connectedWifiType; + final int? signalDecibels; + final LinksysDevice? upstream; + final String connectionType; + final WirelessConnectionInfo? wirelessConnectionInfo; + final String speedMbps; + final List mloList; +} +``` + +## JNAP Models Used (Service Layer Only) + +These models are imported ONLY in `DeviceManagerService`, NOT in the provider: + +| Model | JNAP Action | Purpose | +|-------|-------------|---------| +| `Layer2Connection` | getNetworkConnections | Network connection data | +| `NodeWirelessConnections` | getNodesWirelessNetworkConnections | Mesh node connections | +| `RouterRadio` | getRadioInfo | Radio settings | +| `GuestRadioSettings` | getGuestRadioSettings | Guest network settings | +| `RawDevice` | getDevices | Device list from router | +| `RouterWANStatus` | getWANStatus | WAN connection status | +| `BackHaulInfoData` | getBackhaulInfo | Mesh backhaul info | +| `WirelessConnection` | (derived) | Connection details | + +## Data Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ pollingProvider │ +│ (CoreTransactionData) │ +└─────────────────────┬───────────────────────────────────────┘ + │ Raw JNAP responses + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DeviceManagerService │ +│ transformPollingData(CoreTransactionData?) → │ +│ │ +│ Imports: jnap/models/*, jnap/result/* │ +│ Transforms: JNAP models → DeviceManagerState │ +└─────────────────────┬───────────────────────────────────────┘ + │ DeviceManagerState + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DeviceManagerNotifier │ +│ │ +│ NO imports from jnap/models/ or jnap/result/ │ +│ Delegates all JNAP ops to Service │ +└─────────────────────┬───────────────────────────────────────┘ + │ DeviceManagerState + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Feature Providers │ +│ (device_filtered_list_provider, etc.) │ +│ │ +│ ref.watch(deviceManagerProvider) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Entity Relationships + +``` +DeviceManagerState +├── deviceList: List +│ ├── nodeDevices (computed) +│ └── externalDevices (computed) +├── wirelessConnections: Map +├── radioInfos: Map +├── guestRadioSettings: GuestRadioSettings? +├── wanStatus: RouterWANStatus? +└── backhaulInfoData: List + +LinksysDevice +├── connectedDevices: List (nested for nodes) +├── upstream: LinksysDevice? (parent reference) +└── [inherited from RawDevice] +``` + +## Validation Rules + +| Field | Rule | Enforced In | +|-------|------|-------------| +| deviceList | Can be empty (factory default) | Service | +| wirelessConnections | Can be empty | Service | +| radioInfos | Can be empty | Service | +| lastUpdateTime | Defaults to 0 | State constructor | + +## State Transitions + +This is a **reactive state** driven by polling. No explicit state machine. + +| Trigger | State Change | +|---------|--------------| +| Polling data received | Full state replacement via `transformPollingData()` | +| Device name updated | Partial update to `deviceList` | +| Device deleted | Remove from `deviceList` | +| Client deauthenticated | Triggers polling refresh | diff --git a/specs/001-device-manager-service-extraction/plan.md b/specs/001-device-manager-service-extraction/plan.md new file mode 100644 index 000000000..f808ea9ce --- /dev/null +++ b/specs/001-device-manager-service-extraction/plan.md @@ -0,0 +1,76 @@ +# Implementation Plan: Device Manager Service Extraction + +**Branch**: `001-device-manager-service-extraction` | **Date**: 2025-12-28 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/001-device-manager-service-extraction/spec.md` + +## Summary + +Extract `DeviceManagerService` from `DeviceManagerNotifier` to enforce three-layer architecture compliance per constitution Article V, VI, and XIII. The service will encapsulate all JNAP communication (8 model imports, 9 JNAP actions) and data transformation logic, while the notifier becomes a thin state holder that delegates to the service. + +## Technical Context + +**Language/Version**: Dart 3.0+, Flutter 3.3+ +**Primary Dependencies**: flutter_riverpod 2.6.1, equatable 2.0.5 +**Storage**: N/A (state management only) +**Testing**: flutter_test, mocktail 1.0.4 +**Target Platform**: iOS, Android, Web (multi-platform Flutter app) +**Project Type**: Mobile app with shared core infrastructure +**Performance Goals**: No regression from current behavior +**Constraints**: Must maintain backward compatibility with all consumers of `deviceManagerProvider` +**Scale/Scope**: Core infrastructure provider used by 10+ feature modules + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Article | Requirement | Status | Notes | +|---------|-------------|--------|-------| +| **Article I** | Test coverage (Service ≥90%, Provider ≥85%) | ✅ PASS | SC-003, SC-004 define coverage targets | +| **Article V** | Three-layer architecture compliance | ✅ PASS | Primary goal of this feature | +| **Article VI** | Service layer for JNAP communication | ✅ PASS | Creating DeviceManagerService | +| **Article XIII** | ServiceError for error handling | ✅ PASS | FR-008 requires error mapping | +| **Article III** | Naming conventions | ✅ PASS | Following [feature]Service pattern | +| **Article XI** | Models implement Equatable | ✅ PASS | DeviceManagerState already compliant | + +**Gate Result**: ✅ PASS - All constitutional requirements satisfied + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-device-manager-service-extraction/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── device_manager_service_contract.md +└── tasks.md # Phase 2 output (via /speckit.tasks) +``` + +### Source Code (repository root) + +```text +lib/core/jnap/ +├── providers/ +│ ├── device_manager_provider.dart # MODIFY: Remove JNAP imports, delegate to service +│ └── device_manager_state.dart # KEEP: No changes needed +└── services/ # CREATE: New directory + └── device_manager_service.dart # CREATE: New service + +test/core/jnap/ +├── providers/ +│ └── device_manager_provider_test.dart # CREATE: Provider tests +└── services/ + └── device_manager_service_test.dart # CREATE: Service tests + +test/mocks/test_data/ +└── device_manager_test_data.dart # CREATE: Test data builder +``` + +**Structure Decision**: Service placed in `lib/core/jnap/services/` (infrastructure location) since this is core infrastructure, not a feature-specific provider. This follows the existing pattern where infrastructure code lives under `lib/core/`. + +## Complexity Tracking + +> No violations requiring justification - design follows minimal structure. diff --git a/specs/001-device-manager-service-extraction/quickstart.md b/specs/001-device-manager-service-extraction/quickstart.md new file mode 100644 index 000000000..ff3c6dfb7 --- /dev/null +++ b/specs/001-device-manager-service-extraction/quickstart.md @@ -0,0 +1,148 @@ +# Quickstart: Device Manager Service Extraction + +**Feature**: 001-device-manager-service-extraction +**Date**: 2025-12-28 + +## Overview + +This guide provides the essential information to implement the DeviceManagerService extraction. + +## Goal + +Extract JNAP communication logic from `DeviceManagerNotifier` to `DeviceManagerService` so that: +1. Provider has zero imports from `jnap/models/`, `jnap/result/`, or `jnap/actions/` +2. Service handles all JNAP communication and data transformation +3. All existing functionality works identically + +## Files to Create + +### 1. DeviceManagerService +**Path**: `lib/core/jnap/services/device_manager_service.dart` + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +// ... all JNAP model imports + +final deviceManagerServiceProvider = Provider((ref) { + return DeviceManagerService(ref.watch(routerRepositoryProvider)); +}); + +class DeviceManagerService { + final RouterRepository _routerRepository; + + DeviceManagerService(this._routerRepository); + + /// Move ALL transformation logic from DeviceManagerNotifier.createState() here + DeviceManagerState transformPollingData(CoreTransactionData? pollingResult) { + // Copy the entire createState() implementation + // Move _getWirelessConnections, _getDeviceListAndLocations, etc. + } + + Future> updateDeviceNameAndIcon({...}) async { + // Move from DeviceManagerNotifier.updateDeviceNameAndIcon() + } + + Future> deleteDevices(List deviceIds) async { + // Move from DeviceManagerNotifier.deleteDevices() + } + + Future deauthClient(String macAddress) async { + // Move from DeviceManagerNotifier.deauthClient() + } +} +``` + +### 2. Service Tests +**Path**: `test/core/jnap/services/device_manager_service_test.dart` + +### 3. Provider Tests +**Path**: `test/core/jnap/providers/device_manager_provider_test.dart` + +### 4. Test Data Builder +**Path**: `test/mocks/test_data/device_manager_test_data.dart` + +## Files to Modify + +### DeviceManagerNotifier +**Path**: `lib/core/jnap/providers/device_manager_provider.dart` + +**Remove these imports**: +```dart +// DELETE these lines: +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/command/base_command.dart'; +import 'package:privacy_gui/core/jnap/models/back_haul_info.dart'; +// ... all jnap/models imports +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +``` + +**Keep these imports**: +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/services/device_manager_service.dart'; // ADD +``` + +**Modify build()**: +```dart +@override +DeviceManagerState build() { + final coreTransactionData = ref.watch(pollingProvider).value; + final service = ref.read(deviceManagerServiceProvider); + return service.transformPollingData(coreTransactionData); +} +``` + +**Keep these methods in Notifier** (they query cached state, not JNAP): +- `isEmptyState()` +- `getSsidConnectedBy()` +- `getBandConnectedBy()` +- `findParent()` +- `_getWirelessSignalOf()` (private helper) +- `_getBandFromKnownInterfacesOf()` (private helper) + +## Implementation Order + +1. **Create service file** with empty methods +2. **Move transformation logic** (`createState()` and helpers) +3. **Move write operations** (`updateDeviceNameAndIcon`, `deleteDevices`, `deauthClient`) +4. **Update provider** to use service +5. **Remove JNAP imports** from provider +6. **Write tests** for service +7. **Write tests** for provider +8. **Verify** with architecture compliance check + +## Verification Commands + +```bash +# Check provider has no JNAP imports +grep -E "import.*jnap/(models|result|actions)" lib/core/jnap/providers/device_manager_provider.dart +# Expected: No output + +# Check service has JNAP imports +grep -E "import.*jnap/models" lib/core/jnap/services/device_manager_service.dart +# Expected: Multiple imports + +# Run tests +flutter test test/core/jnap/services/device_manager_service_test.dart +flutter test test/core/jnap/providers/device_manager_provider_test.dart + +# Run all tests to check for regressions +./run_tests.sh +``` + +## Common Pitfalls + +1. **Don't move state query methods** - Methods like `getSsidConnectedBy()` stay in Notifier +2. **Keep DeviceManagerState imports** - The state class is the public API, not a JNAP model +3. **Handle null polling data** - Service must return empty/default state, not throw +4. **Maintain polling refresh** - After write operations, trigger `pollingProvider.notifier.forcePolling()` + +## Reference + +- **Spec**: [spec.md](./spec.md) +- **Research**: [research.md](./research.md) +- **Contract**: [contracts/device_manager_service_contract.md](./contracts/device_manager_service_contract.md) +- **Similar Implementation**: `lib/page/advanced_settings/firewall/services/firewall_settings_service.dart` diff --git a/specs/001-device-manager-service-extraction/research.md b/specs/001-device-manager-service-extraction/research.md new file mode 100644 index 000000000..d0af810d0 --- /dev/null +++ b/specs/001-device-manager-service-extraction/research.md @@ -0,0 +1,192 @@ +# Research: Device Manager Service Extraction + +**Feature**: 001-device-manager-service-extraction +**Date**: 2025-12-28 + +## Research Summary + +This document captures technical decisions and research findings for the DeviceManagerService extraction. + +--- + +## Decision 1: Service Location + +**Question**: Where should `DeviceManagerService` be located? + +**Decision**: `lib/core/jnap/services/device_manager_service.dart` + +**Rationale**: +- `DeviceManagerNotifier` is infrastructure code in `lib/core/jnap/providers/`, not feature-specific +- Following the pattern where related code stays in the same domain (`core/jnap`) +- Creating a new `services/` directory under `core/jnap/` maintains cohesion +- Constitution Article VI Section 6.3 specifies `lib/page/[feature]/services/` for feature code, but this is infrastructure + +**Alternatives Considered**: +- `lib/core/services/device_manager_service.dart` - Rejected: Too far from related JNAP code +- `lib/page/components/services/` - Rejected: Not a feature, it's infrastructure + +--- + +## Decision 2: State Class Location + +**Question**: Should `DeviceManagerState` move or stay? + +**Decision**: Keep in `lib/core/jnap/providers/device_manager_state.dart` + +**Rationale**: +- State class is the public API consumed by 10+ feature providers +- Moving it would break all downstream consumers +- Constitution doesn't mandate state class location for infrastructure code +- The state class acts as the "UI model" for this infrastructure provider + +**Alternatives Considered**: +- Move to `lib/core/jnap/services/` - Rejected: Would require updating all consumers +- Create new UI model class - Rejected: Over-engineering per Article V Section 5.4 + +--- + +## Decision 3: JNAP Model Handling in State + +**Question**: `DeviceManagerState` imports JNAP models (e.g., `LinksysDevice`, `RouterRadio`). Is this a violation? + +**Decision**: No violation - `DeviceManagerState` is infrastructure, not presentation layer + +**Rationale**: +- Constitution Article V Section 5.3 specifically prohibits JNAP models in `lib/page/*/providers/` and `lib/page/*/views/` +- `DeviceManagerState` lives in `lib/core/jnap/providers/` (infrastructure layer) +- The state class effectively IS the application-layer model for device management +- Feature providers watch `deviceManagerProvider` and receive `DeviceManagerState`, not JNAP models + +**Verification**: +```bash +# Check that feature providers don't import JNAP models +grep -r "import.*jnap/models" lib/page/*/providers/ +# Should not include device_manager_state.dart imports +``` + +--- + +## Decision 4: Helper Methods in Notifier + +**Question**: Methods like `getSsidConnectedBy()`, `getBandConnectedBy()`, `findParent()` - should they move to Service? + +**Decision**: Keep in `DeviceManagerNotifier` + +**Rationale**: +- These methods query **cached state**, not JNAP +- They don't make API calls or use RouterRepository +- Moving them to Service would add unnecessary complexity +- Notifier is appropriate for state-derived calculations + +**Methods to KEEP in Notifier**: +- `getSsidConnectedBy(LinksysDevice)` - queries radioInfos from state +- `getBandConnectedBy(LinksysDevice)` - queries wirelessConnections from state +- `findParent(String deviceID)` - navigates cached device list +- `isEmptyState()` - simple state check + +**Methods to MOVE to Service**: +- `createState()` - JNAP data transformation +- `updateDeviceNameAndIcon()` - JNAP API call +- `deleteDevices()` - JNAP API call +- `deauthClient()` - JNAP API call + +--- + +## Decision 5: Error Handling Strategy + +**Question**: What ServiceError types are needed for device operations? + +**Decision**: Use existing `UnexpectedError` for most cases; add specific types only if needed + +**Rationale**: +- Current code doesn't have specific error handling for device operations +- Device update/delete failures are rare and generic error handling suffices +- Following YAGNI principle - add specific error types when UX requires them + +**Error Mapping**: +```dart +ServiceError _mapJnapError(JNAPError error) { + return switch (error.result) { + // Add specific mappings only if UX requires differentiated handling + _ => UnexpectedError(originalError: error, message: error.result), + }; +} +``` + +--- + +## Decision 6: Test Data Builder Pattern + +**Question**: How to structure test data for the complex polling response? + +**Decision**: Create `DeviceManagerTestData` class with factory methods for each JNAP action + +**Rationale**: +- Constitution Article I Section 1.6.2 mandates Test Data Builder pattern +- Polling response contains 7 different JNAP action results +- Centralized test data enables consistent testing across Service and Provider tests + +**Structure**: +```dart +class DeviceManagerTestData { + // Individual action responses + static JNAPSuccess createGetDevicesSuccess({...}); + static JNAPSuccess createGetNetworkConnectionsSuccess({...}); + static JNAPSuccess createGetRadioInfoSuccess({...}); + // ... etc for each action + + // Complete transaction + static CoreTransactionData createCompletePollingData({...}); + + // Error scenarios + static CoreTransactionData createPartialErrorData({...}); +} +``` + +--- + +## Decision 7: Provider-Service Dependency Direction + +**Question**: How should `DeviceManagerNotifier` access `DeviceManagerService`? + +**Decision**: Use `ref.read(deviceManagerServiceProvider)` in methods + +**Rationale**: +- Service is stateless, so `ref.read()` is appropriate (not `ref.watch()`) +- Constitution Article XII Section 12.2 shows this pattern +- Service doesn't change, only provides methods + +**Pattern**: +```dart +class DeviceManagerNotifier extends Notifier { + @override + DeviceManagerState build() { + final coreTransactionData = ref.watch(pollingProvider).value; + final service = ref.read(deviceManagerServiceProvider); + return service.transformPollingData(coreTransactionData); + } +} +``` + +--- + +## Resolved Research Items + +| Item | Status | Decision | +|------|--------|----------| +| Service location | ✅ Resolved | `lib/core/jnap/services/` | +| State class location | ✅ Resolved | Keep in current location | +| JNAP models in state | ✅ Resolved | Allowed for infrastructure | +| Helper methods | ✅ Resolved | Keep state queries in Notifier | +| Error types | ✅ Resolved | Use existing ServiceError types | +| Test data pattern | ✅ Resolved | Test Data Builder pattern | +| Dependency injection | ✅ Resolved | ref.read() for service access | + +--- + +## References + +- Constitution Article V: Simplicity and Minimal Structure +- Constitution Article VI: The Service Layer Principle +- Constitution Article XIII: Error Handling Strategy +- Reference Implementation: `lib/page/advanced_settings/firewall/services/firewall_settings_service.dart` diff --git a/specs/001-device-manager-service-extraction/spec.md b/specs/001-device-manager-service-extraction/spec.md new file mode 100644 index 000000000..f98ccffac --- /dev/null +++ b/specs/001-device-manager-service-extraction/spec.md @@ -0,0 +1,133 @@ +# Feature Specification: Device Manager Service Extraction + +**Feature Branch**: `001-device-manager-service-extraction` +**Created**: 2025-12-28 +**Status**: Draft +**Input**: User description: "Extract DeviceManagerService from DeviceManagerNotifier to enforce three-layer architecture compliance." + +## Clarifications + +### Session 2025-12-28 + +- Q: When `transformPollingData()` receives null or malformed JNAP responses, how should the service behave? → A: Return empty/default state (preserves current behavior) +- Q: When `deleteDevices()` is called with an empty list of device IDs, how should the service behave? → A: Return early with empty success result (no-op) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Data Transformation Isolation (Priority: P1) + +As a developer, when the DeviceManagerNotifier receives polling data, the JNAP-specific data transformation logic should be handled by a dedicated service so that the provider layer remains free of JNAP model dependencies. + +**Why this priority**: This is the core architectural change. All other functionality depends on this transformation being properly isolated. It directly addresses the constitution Article V violation. + +**Independent Test**: Can be fully tested by mocking the service's `transformPollingData()` method and verifying the provider correctly delegates and consumes the transformed state. + +**Acceptance Scenarios**: + +1. **Given** polling data arrives from `pollingProvider`, **When** `DeviceManagerNotifier.build()` is called, **Then** it delegates to `DeviceManagerService.transformPollingData()` and receives a `DeviceManagerState`. +2. **Given** the service transforms polling data, **When** transformation completes, **Then** all JNAP models are converted internally and only `DeviceManagerState` is exposed. +3. **Given** the provider file, **When** inspecting imports, **Then** no imports from `core/jnap/models/` or `core/jnap/result/` are present. + +--- + +### User Story 2 - Device Property Updates (Priority: P2) + +As a user, when I update a device's name or icon, the system should persist these changes via the router API, with all JNAP communication handled by the service layer. + +**Why this priority**: This is a user-facing write operation that currently violates architecture by directly using RouterRepository in the provider. + +**Independent Test**: Can be tested by calling `updateDeviceNameAndIcon()` and verifying the service makes the correct API calls and returns appropriate results. + +**Acceptance Scenarios**: + +1. **Given** a device ID, new name, and optional icon, **When** `updateDeviceNameAndIcon()` is called, **Then** the service sends the correct JNAP action and updates local state on success. +2. **Given** the API call fails, **When** an error occurs, **Then** the service throws a `ServiceError` that the provider can handle appropriately. + +--- + +### User Story 3 - Device Deletion (Priority: P2) + +As a user, when I delete devices from my network, the system should remove them via the router API with proper error handling at the service layer. + +**Why this priority**: Another user-facing write operation requiring service extraction. + +**Independent Test**: Can be tested by calling `deleteDevices()` with device IDs and verifying service handles bulk deletion and error cases. + +**Acceptance Scenarios**: + +1. **Given** a list of device IDs, **When** `deleteDevices()` is called, **Then** the service deletes each device and updates local state for successfully deleted devices. +2. **Given** some deletions fail, **When** partial success occurs, **Then** the service reports which devices were deleted and which failed. + +--- + +### User Story 4 - Client Deauthentication (Priority: P3) + +As a user, when I disconnect a client device from my network, the system should deauthenticate it via the router API through the service layer. + +**Why this priority**: Less frequently used operation, but still requires service extraction for consistency. + +**Independent Test**: Can be tested by calling `deauthClient()` with a MAC address and verifying the service makes the correct API call. + +**Acceptance Scenarios**: + +1. **Given** a device MAC address, **When** `deauthClient()` is called, **Then** the service sends the clientDeauth action. +2. **Given** deauthentication completes, **When** successful, **Then** the provider triggers a polling refresh. + +--- + +### Edge Cases + +- **Null/malformed polling data**: Service returns empty/default `DeviceManagerState` (preserves current behavior). +- **Partial transformation failures**: Service processes available data and skips failed JNAP actions, returning partial state with available information. +- **Empty device list for deletion**: Service returns early with empty success result (no-op); no API calls made. +- **Concurrent update operations**: Handled by existing polling mechanism; no additional synchronization required. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST create a `DeviceManagerService` class that encapsulates all JNAP communication and data transformation logic currently in `DeviceManagerNotifier`. +- **FR-002**: `DeviceManagerService` MUST provide a `transformPollingData(CoreTransactionData?)` method that transforms raw JNAP polling results into `DeviceManagerState`. +- **FR-003**: `DeviceManagerService` MUST provide an `updateDeviceNameAndIcon()` method that handles the `setDeviceProperties` JNAP action and error mapping. +- **FR-004**: `DeviceManagerService` MUST provide a `deleteDevices(List deviceIds)` method that handles bulk device deletion via RouterRepository. +- **FR-005**: `DeviceManagerService` MUST provide a `deauthClient(String macAddress)` method that handles client deauthentication. +- **FR-006**: `DeviceManagerNotifier` MUST NOT import any modules from `core/jnap/models/`, `core/jnap/result/`, or `core/jnap/actions/`. +- **FR-007**: `DeviceManagerNotifier` MUST delegate all JNAP operations to `DeviceManagerService`. +- **FR-008**: `DeviceManagerService` MUST map JNAP errors to `ServiceError` types as defined in Article XIII of the constitution. +- **FR-009**: All helper methods currently in `DeviceManagerNotifier` that query state (e.g., `getSsidConnectedBy`, `getBandConnectedBy`, `findParent`) MUST remain in the notifier as they operate on cached state, not JNAP. +- **FR-010**: The service MUST be provided via a Riverpod `Provider` following Article VI naming conventions. + +### Key Entities + +- **DeviceManagerService**: New service class responsible for JNAP communication and data transformation. Located in `lib/core/jnap/services/`. +- **DeviceManagerState**: Existing state class containing transformed device data. Remains in `lib/core/jnap/providers/`. +- **CoreTransactionData**: Input from polling provider containing raw JNAP responses. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: `DeviceManagerNotifier` contains zero imports from `core/jnap/models/`, `core/jnap/result/`, or `core/jnap/actions/` directories. +- **SC-002**: All existing functionality (device list display, device updates, deletions, deauthentication) works identically from the user's perspective. +- **SC-003**: `DeviceManagerService` has unit test coverage of at least 90% for all public methods. +- **SC-004**: `DeviceManagerNotifier` has unit test coverage of at least 85% for state management logic. +- **SC-005**: No regression in dependent features (device filtering, topology display, device details) verified by running existing test suite. + +## Assumptions + +- The `DeviceManagerState` class will remain in `lib/core/jnap/providers/` as it is the public API consumed by feature providers. +- Helper methods that query cached state (not JNAP) will remain in the notifier for performance and simplicity. +- The service will be placed in `lib/core/jnap/services/` since it's infrastructure code, not feature-specific. +- Existing consumers of `deviceManagerProvider` will not require changes as the public API remains unchanged. + +## Dependencies + +- Depends on existing `ServiceError` infrastructure defined in `lib/core/errors/service_error.dart`. +- Depends on `RouterRepository` for JNAP command execution. +- Depends on `pollingProvider` for raw JNAP transaction data. + +## Out of Scope + +- Refactoring `DeviceManagerState` to use UI Models (state is already the "UI model" for this infrastructure provider). +- Changes to downstream feature providers that consume `deviceManagerProvider`. +- Performance optimizations beyond the scope of service extraction. diff --git a/specs/001-device-manager-service-extraction/tasks.md b/specs/001-device-manager-service-extraction/tasks.md new file mode 100644 index 000000000..b3228e448 --- /dev/null +++ b/specs/001-device-manager-service-extraction/tasks.md @@ -0,0 +1,256 @@ +# Tasks: Device Manager Service Extraction + +**Input**: Design documents from `/specs/001-device-manager-service-extraction/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/device_manager_service_contract.md, quickstart.md + +**Tests**: Included per spec requirements (SC-003: Service ≥90%, SC-004: Provider ≥85%) + +**Organization**: Tasks grouped by user story to enable independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) +- Include exact file paths in descriptions + +## Path Conventions + +- **Source**: `lib/core/jnap/` (infrastructure code) +- **Tests**: `test/core/jnap/` (mirrors source structure) +- **Test Data**: `test/mocks/test_data/` + +--- + +## Phase 1: Setup + +**Purpose**: Create service infrastructure and test data builder + +- [x] T001 Create services directory at `lib/core/jnap/services/` +- [x] T002 [P] Create test services directory at `test/core/jnap/services/` +- [x] T003 [P] Create test data builder file at `test/mocks/test_data/device_manager_test_data.dart` + +--- + +## Phase 2: Foundational (Test Data Builder) + +**Purpose**: Test data infrastructure that MUST be complete before user story implementation + +**⚠️ CRITICAL**: All user story tests depend on the test data builder + +- [x] T004 Implement `DeviceManagerTestData.createGetDevicesSuccess()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T005 [P] Implement `DeviceManagerTestData.createGetNetworkConnectionsSuccess()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T006 [P] Implement `DeviceManagerTestData.createGetRadioInfoSuccess()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T007 [P] Implement `DeviceManagerTestData.createGetGuestRadioSettingsSuccess()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T008 [P] Implement `DeviceManagerTestData.createGetWANStatusSuccess()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T009 [P] Implement `DeviceManagerTestData.createGetBackhaulInfoSuccess()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T010 Implement `DeviceManagerTestData.createCompletePollingData()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T011 [P] Implement `DeviceManagerTestData.createNullPollingData()` in `test/mocks/test_data/device_manager_test_data.dart` +- [x] T012 [P] Implement `DeviceManagerTestData.createPartialErrorData()` in `test/mocks/test_data/device_manager_test_data.dart` + +**Checkpoint**: Test data builder ready - user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Data Transformation Isolation (Priority: P1) 🎯 MVP + +**Goal**: Extract JNAP data transformation logic from DeviceManagerNotifier to DeviceManagerService + +**Independent Test**: Mock service's `transformPollingData()` and verify provider correctly delegates and consumes transformed state. Verify provider file has zero JNAP model imports. + +### Tests for User Story 1 + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [x] T013 [P] [US1] Create service test file at `test/core/jnap/services/device_manager_service_test.dart` +- [x] T014 [P] [US1] Test `transformPollingData` with null input returns empty state in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T015 [P] [US1] Test `transformPollingData` with valid data returns complete state in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T016 [P] [US1] Test `transformPollingData` with partial error data returns partial state in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T017 [P] [US1] Create provider test file at `test/core/jnap/providers/device_manager_provider_test.dart` +- [x] T018 [US1] Test provider `build()` delegates to service in `test/core/jnap/providers/device_manager_provider_test.dart` + +### Implementation for User Story 1 + +- [x] T019 [US1] Create `DeviceManagerService` class skeleton with constructor in `lib/core/jnap/services/device_manager_service.dart` +- [x] T020 [US1] Create `deviceManagerServiceProvider` Riverpod provider in `lib/core/jnap/services/device_manager_service.dart` +- [x] T021 [US1] Copy `_getWirelessConnections()` helper from notifier to service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T022 [US1] Copy `_getDeviceListAndLocations()` helper from notifier to service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T023 [US1] Copy all remaining transformation helpers from notifier to service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T024 [US1] Implement `transformPollingData()` method using copied helpers in `lib/core/jnap/services/device_manager_service.dart` +- [x] T025 [US1] Update `DeviceManagerNotifier.build()` to delegate to service in `lib/core/jnap/providers/device_manager_provider.dart` +- [x] T026 [US1] Remove JNAP model imports from notifier in `lib/core/jnap/providers/device_manager_provider.dart` +- [x] T027 [US1] Remove JNAP result imports from notifier in `lib/core/jnap/providers/device_manager_provider.dart` +- [x] T028 [US1] Remove JNAP action imports from notifier in `lib/core/jnap/providers/device_manager_provider.dart` +- [x] T029 [US1] Delete transformation helper methods from notifier in `lib/core/jnap/providers/device_manager_provider.dart` + +**Checkpoint**: US1 complete - Provider has zero JNAP imports, transformation delegated to service + +--- + +## Phase 4: User Story 2 - Device Property Updates (Priority: P2) + +**Goal**: Extract device name/icon update logic to service layer + +**Independent Test**: Call `updateDeviceNameAndIcon()` and verify service makes correct API calls and returns appropriate results. + +### Tests for User Story 2 + +- [x] T030 [P] [US2] Test `updateDeviceNameAndIcon` success case in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T031 [P] [US2] Test `updateDeviceNameAndIcon` error throws ServiceError in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T032 [US2] Test provider `updateDeviceNameAndIcon` delegates to service in `test/core/jnap/providers/device_manager_provider_test.dart` + +### Implementation for User Story 2 + +- [x] T033 [US2] Move `updateDeviceNameAndIcon()` logic from notifier to service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T034 [US2] Add JNAP error to ServiceError mapping for device updates in `lib/core/jnap/services/device_manager_service.dart` +- [x] T035 [US2] Update notifier `updateDeviceNameAndIcon()` to delegate to service in `lib/core/jnap/providers/device_manager_provider.dart` + +**Checkpoint**: US2 complete - Device updates work through service layer + +--- + +## Phase 5: User Story 3 - Device Deletion (Priority: P2) + +**Goal**: Extract device deletion logic to service layer + +**Independent Test**: Call `deleteDevices()` with device IDs and verify service handles bulk deletion and returns success map. + +### Tests for User Story 3 + +- [x] T036 [P] [US3] Test `deleteDevices` with empty list returns empty map in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T037 [P] [US3] Test `deleteDevices` with valid IDs returns success map in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T038 [P] [US3] Test `deleteDevices` partial failure returns mixed map in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T039 [US3] Test provider `deleteDevices` delegates to service in `test/core/jnap/providers/device_manager_provider_test.dart` + +### Implementation for User Story 3 + +- [x] T040 [US3] Move `deleteDevices()` logic from notifier to service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T041 [US3] Add early return for empty device list in service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T042 [US3] Update notifier `deleteDevices()` to delegate to service in `lib/core/jnap/providers/device_manager_provider.dart` + +**Checkpoint**: US3 complete - Device deletion works through service layer + +--- + +## Phase 6: User Story 4 - Client Deauthentication (Priority: P3) + +**Goal**: Extract client deauthentication logic to service layer + +**Independent Test**: Call `deauthClient()` with MAC address and verify service makes correct API call. + +### Tests for User Story 4 + +- [x] T043 [P] [US4] Test `deauthClient` success case in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T044 [P] [US4] Test `deauthClient` error throws ServiceError in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T045 [US4] Test provider `deauthClient` delegates to service in `test/core/jnap/providers/device_manager_provider_test.dart` + +### Implementation for User Story 4 + +- [x] T046 [US4] Move `deauthClient()` logic from notifier to service in `lib/core/jnap/services/device_manager_service.dart` +- [x] T047 [US4] Add error handling with ServiceError mapping in `lib/core/jnap/services/device_manager_service.dart` +- [x] T048 [US4] Update notifier `deauthClient()` to delegate to service in `lib/core/jnap/providers/device_manager_provider.dart` + +**Checkpoint**: US4 complete - All JNAP operations now handled by service + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Verification, cleanup, and coverage validation + +- [x] T049 Verify provider has zero JNAP imports using grep command +- [x] T050 [P] Run service tests and verify ≥90% coverage in `test/core/jnap/services/device_manager_service_test.dart` +- [x] T051 [P] Run provider tests and verify ≥85% coverage in `test/core/jnap/providers/device_manager_provider_test.dart` +- [x] T052 Run full test suite to verify no regressions (`./run_tests.sh`) +- [x] T053 Complete implementation checklist at `specs/001-device-manager-service-extraction/checklists/implementation.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3-6)**: All depend on Foundational phase completion + - User stories can proceed sequentially in priority order (P1 → P2 → P2 → P3) + - US2 and US3 are both P2 - can be done in parallel if desired +- **Polish (Phase 7)**: Depends on all user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after US1 complete (needs service infrastructure) +- **User Story 3 (P2)**: Can start after US1 complete (needs service infrastructure) - Can run parallel to US2 +- **User Story 4 (P3)**: Can start after US1 complete (needs service infrastructure) + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Service implementation before provider delegation +- Remove old code after new code works +- Story complete before moving to next priority + +### Parallel Opportunities + +- T002, T003 can run in parallel (different directories) +- T005-T009, T011-T012 can run in parallel (same file, but different factory methods) +- T013-T017 tests can be written in parallel (different test files/groups) +- T030-T031, T036-T038, T043-T044 tests can run in parallel within each story +- US2 and US3 can be implemented in parallel after US1 completes + +--- + +## Parallel Example: User Story 1 Tests + +```bash +# Launch all tests for User Story 1 together: +Task T013: "Create service test file at test/core/jnap/services/device_manager_service_test.dart" +Task T014: "Test transformPollingData with null input" +Task T015: "Test transformPollingData with valid data" +Task T016: "Test transformPollingData with partial error data" +Task T017: "Create provider test file at test/core/jnap/providers/device_manager_provider_test.dart" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (test data builder) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: + - Verify `grep -E "import.*jnap/(models|result|actions)" lib/core/jnap/providers/device_manager_provider.dart` returns nothing + - Run `./run_tests.sh` to verify no regressions +5. MVP complete - architecture compliance achieved + +### Incremental Delivery + +1. Complete Setup + Foundational → Test data ready +2. Add User Story 1 → Test independently → **MVP Complete!** (Architecture compliance) +3. Add User Story 2 → Test independently → Device updates via service +4. Add User Story 3 → Test independently → Device deletion via service +5. Add User Story 4 → Test independently → All operations via service +6. Polish phase → Coverage verified, checklist complete + +### Parallel Team Strategy + +With multiple developers after US1 is complete: +- Developer A: User Story 2 (device updates) +- Developer B: User Story 3 (device deletion) +- Developer C: User Story 4 (deauthentication) + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story is independently testable after Phase 2 +- Verify tests fail before implementing +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- **Critical files to modify**: + - `lib/core/jnap/providers/device_manager_provider.dart` (remove JNAP imports, delegate to service) + - `lib/core/jnap/services/device_manager_service.dart` (new - all JNAP logic) diff --git a/specs/005-dashboard-service-extraction/checklists/requirements.md b/specs/005-dashboard-service-extraction/checklists/requirements.md new file mode 100644 index 000000000..e4a93f0a3 --- /dev/null +++ b/specs/005-dashboard-service-extraction/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Dashboard Manager Service Extraction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation +- Spec is ready for `/speckit.clarify` or `/speckit.plan` +- This is a refactoring task with clear architectural boundaries based on constitution.md Article V, VI, XIII +- Reference implementation exists: DeviceManagerService (same pattern applied to DashboardManager) diff --git a/specs/005-dashboard-service-extraction/contracts/dashboard_manager_service_contract.md b/specs/005-dashboard-service-extraction/contracts/dashboard_manager_service_contract.md new file mode 100644 index 000000000..1e45e511e --- /dev/null +++ b/specs/005-dashboard-service-extraction/contracts/dashboard_manager_service_contract.md @@ -0,0 +1,240 @@ +# Service Contract: DashboardManagerService + +**Version**: 1.0 +**Date**: 2025-12-29 +**Location**: `lib/core/jnap/services/dashboard_manager_service.dart` + +## Overview + +Stateless service that encapsulates all JNAP communication for dashboard functionality. Transforms raw polling data into `DashboardManagerState` and provides on-demand device info operations. + +--- + +## Provider Definition + +```dart +final dashboardManagerServiceProvider = Provider((ref) { + return DashboardManagerService(ref.watch(routerRepositoryProvider)); +}); +``` + +--- + +## Class Definition + +```dart +class DashboardManagerService { + final RouterRepository _routerRepository; + + DashboardManagerService(this._routerRepository); + + // Public methods defined below +} +``` + +--- + +## Method Contracts + +### transformPollingData + +Transforms raw JNAP polling data into `DashboardManagerState`. + +```dart +/// Transforms polling data into DashboardManagerState. +/// +/// [pollingResult] - Raw JNAP transaction data from pollingProvider. +/// Can be null during initial load. +/// +/// Returns: Complete DashboardManagerState with all dashboard information. +/// +/// Behavior: +/// - If [pollingResult] is null, returns empty default state +/// - Processes all available JNAP action results +/// - Skips failed actions gracefully (uses defaults for those fields) +/// - Never throws - always returns valid state +/// +/// JNAP Actions Processed: +/// - getDeviceInfo → deviceInfo +/// - getRadioInfo → mainRadios +/// - getGuestRadioSettings → guestRadios, isGuestNetworkEnabled +/// - getSystemStats → uptimes, cpuLoad, memoryLoad +/// - getEthernetPortConnections → wanConnection, lanConnections +/// - getLocalTime → localTime +/// - getSoftSKUSettings → skuModelNumber +DashboardManagerState transformPollingData(CoreTransactionData? pollingResult); +``` + +**Input/Output**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `pollingResult` | `CoreTransactionData?` | No | Raw polling data from pollingProvider | +| **Returns** | `DashboardManagerState` | - | Transformed state, never null | + +**Example Usage**: + +```dart +// In DashboardManagerNotifier.build() +DashboardManagerState build() { + final coreTransactionData = ref.watch(pollingProvider).value; + final service = ref.read(dashboardManagerServiceProvider); + return service.transformPollingData(coreTransactionData); +} +``` + +--- + +### checkRouterIsBack + +Verifies router is accessible and matches expected serial number. + +```dart +/// Verifies router connectivity and serial number matching. +/// +/// [expectedSerialNumber] - Serial number to match against router response. +/// +/// Returns: NodeDeviceInfo if router is accessible and SN matches. +/// +/// Throws: +/// - [SerialNumberMismatchError] if router responds but SN doesn't match +/// - [ConnectivityError] if router is not accessible +/// - [UnexpectedError] for other failures +Future checkRouterIsBack(String expectedSerialNumber); +``` + +**Input/Output**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `expectedSerialNumber` | `String` | Yes | Expected serial number from stored preferences | +| **Returns** | `NodeDeviceInfo` | - | Device info on success | +| **Throws** | `ServiceError` | - | On any failure | + +**Example Usage**: + +```dart +// In DashboardManagerNotifier +Future checkRouterIsBack() async { + final service = ref.read(dashboardManagerServiceProvider); + final prefs = await SharedPreferences.getInstance(); + final currentSN = prefs.getString(pCurrentSN) ?? prefs.getString(pPnpConfiguredSN); + return service.checkRouterIsBack(currentSN ?? ''); +} +``` + +--- + +### checkDeviceInfo + +Fetches device info, using API call only if needed. + +```dart +/// Fetches device information on-demand. +/// +/// [cachedDeviceInfo] - Currently cached device info from state (may be null). +/// +/// Returns: NodeDeviceInfo from cache if available, otherwise from API. +/// +/// Throws: +/// - [ConnectivityError] if API call fails +/// - [UnexpectedError] for other failures +/// +/// Note: This method receives cached value as parameter rather than +/// accessing provider state directly, keeping service stateless. +Future checkDeviceInfo(NodeDeviceInfo? cachedDeviceInfo); +``` + +**Input/Output**: + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `cachedDeviceInfo` | `NodeDeviceInfo?` | No | Cached value from current state | +| **Returns** | `NodeDeviceInfo` | - | Cached or fresh device info | +| **Throws** | `ServiceError` | - | If API call fails | + +**Example Usage**: + +```dart +// In DashboardManagerNotifier +Future checkDeviceInfo(String? serialNumber) async { + final service = ref.read(dashboardManagerServiceProvider); + return service.checkDeviceInfo(state.deviceInfo); +} +``` + +--- + +## Error Handling + +### ServiceError Types Used + +```dart +// From lib/core/errors/service_error.dart + +/// Thrown when serial number doesn't match expected value +final class SerialNumberMismatchError extends ServiceError { + final String expected; + final String actual; + const SerialNumberMismatchError({required this.expected, required this.actual}); +} + +/// Thrown when router is not accessible +final class ConnectivityError extends ServiceError { + final String? message; + const ConnectivityError({this.message}); +} + +/// Thrown for unexpected failures +final class UnexpectedError extends ServiceError { + final Object? originalError; + final String? message; + const UnexpectedError({this.originalError, this.message}); +} +``` + +### Error Mapping + +```dart +ServiceError _mapJnapError(JNAPError error) { + return switch (error.result) { + '_ErrorUnauthorized' => const UnauthorizedError(), + _ => UnexpectedError(originalError: error, message: error.result), + }; +} +``` + +--- + +## Dependencies + +| Dependency | Type | Purpose | +|------------|------|---------| +| `RouterRepository` | Constructor injection | JNAP API communication | +| `JNAPAction` | Static reference | Action identifiers | +| `JNAPSuccess`, `JNAPError` | Types | Response/error handling | +| JNAP Models | Imports | Data parsing | + +--- + +## Testing Contract + +### Required Test Cases + +**transformPollingData()**: +- [ ] Returns default state when pollingResult is null +- [ ] Returns complete state when all actions succeed +- [ ] Returns partial state when some actions fail +- [ ] Correctly parses each JNAP action response +- [ ] Uses default localTime when parsing fails + +**checkRouterIsBack()**: +- [ ] Returns NodeDeviceInfo when SN matches +- [ ] Throws SerialNumberMismatchError when SN doesn't match +- [ ] Throws ConnectivityError when router unreachable +- [ ] Maps JNAPError to ServiceError correctly + +**checkDeviceInfo()**: +- [ ] Returns cached value immediately when available +- [ ] Makes API call when cached value is null +- [ ] Throws ServiceError on API failure diff --git a/specs/005-dashboard-service-extraction/data-model.md b/specs/005-dashboard-service-extraction/data-model.md new file mode 100644 index 000000000..37e8e95f6 --- /dev/null +++ b/specs/005-dashboard-service-extraction/data-model.md @@ -0,0 +1,147 @@ +# Data Model: Dashboard Manager Service Extraction + +**Date**: 2025-12-29 +**Status**: Complete + +## Overview + +This refactoring does not introduce new data models. It reorganizes existing data flow between layers. + +--- + +## Existing Entities (Unchanged) + +### DashboardManagerState + +**Location**: `lib/core/jnap/providers/dashboard_manager_state.dart` +**Role**: UI state for dashboard, consumed by views + +```dart +class DashboardManagerState extends Equatable { + final NodeDeviceInfo? deviceInfo; + final List mainRadios; + final List guestRadios; + final bool isGuestNetworkEnabled; + final int uptimes; + final String? wanConnection; + final List lanConnections; + final String? skuModelNumber; + final int localTime; + final String? cpuLoad; + final String? memoryLoad; +} +``` + +**Decision**: No UI Model needed per Article V Section 5.3.4: +- ❌ Not a collection type requiring separate items +- ❌ Not reused across multiple features +- ❌ Not deeply nested (flat structure with ~10 fields) +- ❌ No complex computed properties needed + +--- + +## JNAP Models (Referenced by Service Only) + +These models are imported by `DashboardManagerService` but NOT by `DashboardManagerNotifier`: + +| Model | Source | Used For | +|-------|--------|----------| +| `NodeDeviceInfo` | `jnap/models/device_info.dart` | Device identification, serial number | +| `RouterRadio` | `jnap/models/radio_info.dart` | Main WiFi radio settings | +| `GuestRadioInfo` | `jnap/models/guest_radio_settings.dart` | Guest network radio settings | +| `GuestRadioSettings` | `jnap/models/guest_radio_settings.dart` | Guest network enabled state | +| `GetRadioInfo` | `jnap/models/radio_info.dart` | Radio info response parsing | +| `SoftSKUSettings` | `jnap/models/soft_sku_settings.dart` | SKU model number | + +--- + +## Data Flow + +### Before Refactoring (Current) + +``` +┌─────────────────┐ ┌────────────────────────────┐ ┌──────────────┐ +│ pollingProvider │────▶│ DashboardManagerNotifier │────▶│ UI Views │ +│ │ │ - imports jnap/models │ │ │ +│ │ │ - imports jnap/result │ │ │ +│ │ │ - transforms data │ │ │ +└─────────────────┘ └────────────────────────────┘ └──────────────┘ +``` + +### After Refactoring (Target) + +``` +┌─────────────────┐ ┌─────────────────────────┐ ┌────────────────────────────┐ ┌──────────────┐ +│ pollingProvider │────▶│ DashboardManagerService │────▶│ DashboardManagerNotifier │────▶│ UI Views │ +│ │ │ - imports jnap/models │ │ - NO jnap imports │ │ │ +│ │ │ - imports jnap/result │ │ - delegates to service │ │ │ +│ │ │ - transforms data │ │ - manages state lifecycle │ │ │ +└─────────────────┘ └─────────────────────────┘ └────────────────────────────┘ └──────────────┘ +``` + +--- + +## State Transitions + +### DashboardManagerState Lifecycle + +``` +┌─────────────────────┐ +│ Initial (empty) │ +│ - deviceInfo: null │ +│ - mainRadios: [] │ +│ - uptimes: 0 │ +└──────────┬──────────┘ + │ transformPollingData(valid data) + ▼ +┌─────────────────────┐ +│ Populated │ +│ - deviceInfo: {...} │ +│ - mainRadios: [...] │ +│ - uptimes: N │ +└──────────┬──────────┘ + │ transformPollingData(partial failure) + ▼ +┌─────────────────────┐ +│ Partial │ +│ - deviceInfo: {...} │ ← successful action +│ - mainRadios: [] │ ← failed action (default) +│ - uptimes: N │ +└─────────────────────┘ +``` + +--- + +## Validation Rules + +### transformPollingData() + +| Field | Validation | Default | +|-------|------------|---------| +| `deviceInfo` | Optional | `null` | +| `mainRadios` | List, can be empty | `[]` | +| `guestRadios` | List, can be empty | `[]` | +| `isGuestNetworkEnabled` | Boolean | `false` | +| `uptimes` | Integer >= 0 | `0` | +| `wanConnection` | Optional string | `null` | +| `lanConnections` | List of strings | `[]` | +| `skuModelNumber` | Optional string | `null` | +| `localTime` | Integer (milliseconds) | Current device time | +| `cpuLoad` | Optional string | `null` | +| `memoryLoad` | Optional string | `null` | + +### checkRouterIsBack() + +| Condition | Behavior | +|-----------|----------| +| Router accessible, SN matches | Returns `NodeDeviceInfo` | +| Router accessible, SN mismatch | Throws `SerialNumberMismatchError` | +| Router not accessible | Throws `ConnectivityError` | + +### checkDeviceInfo() + +| Condition | Behavior | +|-----------|----------| +| State has deviceInfo | Returns cached value | +| State deviceInfo is null | Makes API call, returns fresh value | +| API call fails | Throws `ServiceError` | diff --git a/specs/005-dashboard-service-extraction/plan.md b/specs/005-dashboard-service-extraction/plan.md new file mode 100644 index 000000000..b89e66002 --- /dev/null +++ b/specs/005-dashboard-service-extraction/plan.md @@ -0,0 +1,76 @@ +# Implementation Plan: Dashboard Manager Service Extraction + +**Branch**: `005-dashboard-service-extraction` | **Date**: 2025-12-29 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/005-dashboard-service-extraction/spec.md` + +## Summary + +Extract JNAP communication and data transformation logic from `DashboardManagerNotifier` into a new `DashboardManagerService` class to enforce three-layer architecture compliance per constitution.md Article V, VI, XIII. The service will handle 7 JNAP actions (getDeviceInfo, getRadioInfo, getGuestRadioSettings, getSystemStats, getEthernetPortConnections, getLocalTime, getSoftSKUSettings) and provide 3 public methods: `transformPollingData()`, `checkRouterIsBack()`, and `checkDeviceInfo()`. + +## Technical Context + +**Language/Version**: Dart 3.0+, Flutter 3.3+ +**Primary Dependencies**: flutter_riverpod 2.6.1, equatable 2.0.5 +**Storage**: N/A (state management only) +**Testing**: flutter_test, mocktail +**Target Platform**: iOS, Android, Web +**Project Type**: Mobile application (Flutter) +**Performance Goals**: No degradation from current polling cycle performance +**Constraints**: Must maintain backward compatibility with existing dashboard UI +**Scale/Scope**: Single provider refactoring, ~150 lines of code to move + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Article | Requirement | Status | Notes | +|---------|-------------|--------|-------| +| **Article I** | Test coverage (Service ≥90%, Provider ≥85%) | ✅ Planned | Test files specified in structure | +| **Article III** | Naming conventions | ✅ Compliant | `DashboardManagerService`, `dashboardManagerServiceProvider` | +| **Article V Section 5.3** | Three-layer architecture | ✅ Target | This refactoring enforces compliance | +| **Article VI** | Service layer principle | ✅ Compliant | Service handles JNAP, returns state | +| **Article VII** | Anti-abstraction (no framework wrappers) | ✅ Compliant | Service is legitimate abstraction | +| **Article XIII** | Error handling (ServiceError) | ✅ Planned | JNAPError → ServiceError mapping | + +**Gate Result**: ✅ PASS - No violations requiring justification + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-dashboard-service-extraction/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── dashboard_manager_service_contract.md +└── tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +lib/core/jnap/ +├── providers/ +│ ├── dashboard_manager_provider.dart # MODIFY: Remove JNAP imports, delegate to service +│ └── dashboard_manager_state.dart # PRESERVE: No changes +└── services/ + └── dashboard_manager_service.dart # CREATE: New service file + +test/core/jnap/ +├── providers/ +│ └── dashboard_manager_provider_test.dart # CREATE: Provider tests +└── services/ + └── dashboard_manager_service_test.dart # CREATE: Service tests + +test/mocks/test_data/ +└── dashboard_manager_test_data.dart # CREATE: Test data builder (per constitution Section 1.6.2) +``` + +**Structure Decision**: Following existing pattern from `lib/core/jnap/services/device_manager_service.dart`. Service placed in `lib/core/jnap/services/` alongside DeviceManagerService since both are core infrastructure services, not page-specific features. + +## Complexity Tracking + +> No violations requiring justification. This is a straightforward refactoring with clear reference implementation (DeviceManagerService). diff --git a/specs/005-dashboard-service-extraction/quickstart.md b/specs/005-dashboard-service-extraction/quickstart.md new file mode 100644 index 000000000..a6bade3f3 --- /dev/null +++ b/specs/005-dashboard-service-extraction/quickstart.md @@ -0,0 +1,180 @@ +# Quickstart: Dashboard Manager Service Extraction + +**Date**: 2025-12-29 +**Estimated Effort**: Small (~2-3 hours) +**Reference**: [DeviceManagerService](../../lib/core/jnap/services/device_manager_service.dart) + +## Prerequisites + +- [x] Understand constitution.md Article V, VI, XIII +- [x] Review DeviceManagerService as reference implementation +- [x] Read current dashboard_manager_provider.dart + +## Implementation Order + +### Step 1: Create Service File + +**File**: `lib/core/jnap/services/dashboard_manager_service.dart` + +1. Copy structure from `device_manager_service.dart` +2. Create `dashboardManagerServiceProvider` +3. Create `DashboardManagerService` class with `RouterRepository` injection +4. Move `createState()` logic → `transformPollingData()` method +5. Move `_getMainRadioList()` and `_getGuestRadioList()` helpers +6. Implement `checkRouterIsBack()` with error handling +7. Implement `checkDeviceInfo()` with caching logic +8. Add `_mapJnapError()` helper for ServiceError conversion + +### Step 2: Refactor Provider + +**File**: `lib/core/jnap/providers/dashboard_manager_provider.dart` + +1. Remove JNAP imports: + - `jnap/actions/better_action.dart` + - `jnap/models/device_info.dart` + - `jnap/models/guest_radio_settings.dart` + - `jnap/models/radio_info.dart` + - `jnap/models/soft_sku_settings.dart` + - `jnap/result/jnap_result.dart` + - `jnap/router_repository.dart` + +2. Add service import: + ```dart + import 'package:privacy_gui/core/jnap/services/dashboard_manager_service.dart'; + ``` + +3. Refactor `build()` method: + ```dart + @override + DashboardManagerState build() { + final coreTransactionData = ref.watch(pollingProvider).value; + final service = ref.read(dashboardManagerServiceProvider); + return service.transformPollingData(coreTransactionData); + } + ``` + +4. Remove `createState()`, `_getMainRadioList()`, `_getGuestRadioList()` methods + +5. Delegate `checkRouterIsBack()`: + ```dart + Future checkRouterIsBack() async { + final service = ref.read(dashboardManagerServiceProvider); + final prefs = await SharedPreferences.getInstance(); + final currentSN = prefs.getString(pCurrentSN) ?? prefs.getString(pPnpConfiguredSN); + return service.checkRouterIsBack(currentSN ?? ''); + } + ``` + +6. Delegate `checkDeviceInfo()`: + ```dart + Future checkDeviceInfo(String? serialNumber) async { + final service = ref.read(dashboardManagerServiceProvider); + return service.checkDeviceInfo(state.deviceInfo); + } + ``` + +7. Keep `saveSelectedNetwork()` unchanged (no JNAP calls) + +### Step 3: Add ServiceError Types (if needed) + +**File**: `lib/core/errors/service_error.dart` + +Check if `SerialNumberMismatchError` and `ConnectivityError` exist. If not, add: + +```dart +final class SerialNumberMismatchError extends ServiceError { + final String expected; + final String actual; + const SerialNumberMismatchError({required this.expected, required this.actual}); +} + +final class ConnectivityError extends ServiceError { + final String? message; + const ConnectivityError({this.message}); +} +``` + +### Step 4: Create Test Data Builder + +**File**: `test/mocks/test_data/dashboard_manager_test_data.dart` + +```dart +class DashboardManagerTestData { + static JNAPSuccess createDeviceInfoSuccess({ + String serialNumber = 'TEST123', + String modelNumber = 'MX5300', + }) => JNAPSuccess( + result: 'OK', + output: { + 'serialNumber': serialNumber, + 'modelNumber': modelNumber, + // ... other fields + }, + ); + + // Add methods for each JNAP action response + // Add createSuccessfulPollingData() for complete test scenarios +} +``` + +### Step 5: Write Service Tests + +**File**: `test/core/jnap/services/dashboard_manager_service_test.dart` + +Test groups: +- `transformPollingData` - null input, complete success, partial failure +- `checkRouterIsBack` - success, SN mismatch, connectivity failure +- `checkDeviceInfo` - cached hit, cache miss with API success, API failure + +### Step 6: Write Provider Tests + +**File**: `test/core/jnap/providers/dashboard_manager_provider_test.dart` + +Test groups: +- `build` - delegates to service correctly +- `checkRouterIsBack` - delegates and handles errors +- `checkDeviceInfo` - delegates with correct cached value + +### Step 7: Verify Architecture Compliance + +Run compliance checks: + +```bash +# Should return 0 results for Provider JNAP imports +grep -r "import.*jnap/models" lib/core/jnap/providers/dashboard_manager_provider.dart +grep -r "import.*jnap/result" lib/core/jnap/providers/dashboard_manager_provider.dart +grep -r "import.*jnap/actions" lib/core/jnap/providers/dashboard_manager_provider.dart + +# Should have results for Service JNAP imports +grep -r "import.*jnap/models" lib/core/jnap/services/dashboard_manager_service.dart +``` + +### Step 8: Run Tests + +```bash +# Run service tests +flutter test test/core/jnap/services/dashboard_manager_service_test.dart + +# Run provider tests +flutter test test/core/jnap/providers/dashboard_manager_provider_test.dart + +# Run all tests to check for regressions +./run_tests.sh +``` + +## Verification Checklist + +- [ ] Service created at correct location +- [ ] Provider has no JNAP imports +- [ ] Service tests pass (≥90% coverage) +- [ ] Provider tests pass (≥85% coverage) +- [ ] Existing dashboard functionality unchanged +- [ ] `flutter analyze` passes +- [ ] Architecture compliance checks pass + +## Common Pitfalls + +1. **Don't forget `intl` import**: The `DateFormat` class is used in time parsing - keep it in service +2. **SharedPreferences**: Keep in provider for `checkRouterIsBack()` - service receives SN as parameter +3. **BenchMarkLogger**: Keep in provider for `checkDeviceInfo()` - or move to service +4. **State access**: Service receives values as parameters, doesn't access provider state diff --git a/specs/005-dashboard-service-extraction/research.md b/specs/005-dashboard-service-extraction/research.md new file mode 100644 index 000000000..dabdf5037 --- /dev/null +++ b/specs/005-dashboard-service-extraction/research.md @@ -0,0 +1,99 @@ +# Research: Dashboard Manager Service Extraction + +**Date**: 2025-12-29 +**Status**: Complete + +## Research Summary + +This refactoring follows an established pattern in the codebase. No unknowns requiring external research. + +--- + +## Decision 1: Service Location + +**Decision**: Place `DashboardManagerService` in `lib/core/jnap/services/` + +**Rationale**: +- Follows existing pattern of `DeviceManagerService` in same directory +- Both are core infrastructure services used across multiple features +- Keeps JNAP-related services co-located with JNAP providers + +**Alternatives Considered**: +- `lib/page/dashboard/services/` - Rejected: DashboardManagerProvider is in `lib/core/jnap/providers/`, not page-specific +- `lib/core/services/` - Rejected: Service is JNAP-specific, not generic + +--- + +## Decision 2: State Class Handling + +**Decision**: Keep `DashboardManagerState` unchanged; Service returns it directly + +**Rationale**: +- State class already exists with proper structure +- State contains JNAP models (`NodeDeviceInfo`, `RouterRadio`, `GuestRadioInfo`) which are domain concepts, not implementation details +- Creating a separate UI model would be over-engineering per Article V Section 5.3.4 + +**Alternatives Considered**: +- Create `DashboardUIModel` - Rejected: No reuse across features, no complex transformations needed +- Transform to primitive types - Rejected: Would lose type safety and require duplicate definitions + +--- + +## Decision 3: Error Handling Pattern + +**Decision**: Use `ServiceError` sealed class for checkRouterIsBack() and checkDeviceInfo() methods; transformPollingData() never throws + +**Rationale**: +- transformPollingData() processes polling data which may have partial failures - should return partial state, not throw +- checkRouterIsBack() and checkDeviceInfo() are imperative operations that can fail - should throw ServiceError +- Follows Article XIII patterns + +**Alternatives Considered**: +- Return Result type - Rejected: Not established pattern in codebase +- Throw for all methods - Rejected: Polling transformation should be resilient + +--- + +## Decision 4: Reference Implementation + +**Decision**: Use `DeviceManagerService` as the primary reference implementation + +**Rationale**: +- Same architectural context (core JNAP provider refactoring) +- Already implements the transformPollingData() pattern +- Uses proper ServiceError mapping +- Recently implemented (branch 001) and validated + +**Reference Files**: +- `lib/core/jnap/services/device_manager_service.dart` +- `lib/core/jnap/providers/device_manager_provider.dart` + +--- + +## Decision 5: Test Data Builder Pattern + +**Decision**: Create `DashboardManagerTestData` class in `test/mocks/test_data/` + +**Rationale**: +- Follows Article I Section 1.6.2 test data builder pattern +- Provides reusable mock JNAP responses +- Supports partial override design for flexible test scenarios + +**Methods to Include**: +- `createDeviceInfoSuccess()` - Mock getDeviceInfo response +- `createRadioInfoSuccess()` - Mock getRadioInfo response +- `createGuestRadioSettingsSuccess()` - Mock getGuestRadioSettings response +- `createSystemStatsSuccess()` - Mock getSystemStats response +- `createEthernetPortConnectionsSuccess()` - Mock getEthernetPortConnections response +- `createLocalTimeSuccess()` - Mock getLocalTime response +- `createSoftSKUSettingsSuccess()` - Mock getSoftSKUSettings response +- `createSuccessfulPollingData()` - Combined polling data with all actions + +--- + +## No Further Research Required + +All technical decisions resolved based on: +1. Existing codebase patterns (DeviceManagerService) +2. Constitution requirements (Article V, VI, XIII) +3. Feature spec acceptance criteria diff --git a/specs/005-dashboard-service-extraction/spec.md b/specs/005-dashboard-service-extraction/spec.md new file mode 100644 index 000000000..4f3a3a356 --- /dev/null +++ b/specs/005-dashboard-service-extraction/spec.md @@ -0,0 +1,142 @@ +# Feature Specification: Dashboard Manager Service Extraction + +**Feature Branch**: `005-dashboard-service-extraction` +**Created**: 2025-12-29 +**Status**: Draft +**Input**: Extract DashboardManagerService from DashboardManagerNotifier to enforce three-layer architecture compliance. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Polling Data Transformation (Priority: P1) + +The system transforms dashboard polling data into UI state without exposing JNAP protocol details to the Provider layer. When polling data arrives, the Service extracts device info, radio settings, system stats, and network connections, then provides a clean DashboardManagerState to the Provider. + +**Why this priority**: This is the core transformation logic that runs on every polling cycle. Without this, the dashboard cannot display any router information. + +**Independent Test**: Can be fully tested by providing mock CoreTransactionData and verifying the returned DashboardManagerState contains correctly transformed values. + +**Acceptance Scenarios**: + +1. **Given** valid polling data with all JNAP actions successful, **When** transformPollingData is called, **Then** DashboardManagerState contains deviceInfo, mainRadios, guestRadios, uptimes, system stats, and network connections. + +2. **Given** polling data with some JNAP actions failed, **When** transformPollingData is called, **Then** DashboardManagerState contains data from successful actions and null/defaults for failed ones. + +3. **Given** null polling data, **When** transformPollingData is called, **Then** DashboardManagerState returns default empty state. + +--- + +### User Story 2 - Router Connectivity Check (Priority: P2) + +Users can verify if the router is accessible and matches the expected serial number. This supports reconnection flows after network changes or app backgrounding. + +**Why this priority**: Critical for session management and reconnection, but less frequent than polling data transformation. + +**Independent Test**: Can be fully tested by mocking RouterRepository.send() and verifying serial number matching logic. + +**Acceptance Scenarios**: + +1. **Given** router is accessible and serial number matches stored value, **When** checkRouterIsBack is called, **Then** returns NodeDeviceInfo successfully. + +2. **Given** router is accessible but serial number does not match, **When** checkRouterIsBack is called, **Then** throws appropriate error. + +3. **Given** router is not accessible, **When** checkRouterIsBack is called, **Then** throws ServiceError indicating connectivity failure. + +--- + +### User Story 3 - Device Info Retrieval (Priority: P2) + +Users can fetch device information on-demand, using cached state when available or making a fresh API call when needed. This supports various UI flows that need quick access to router information. + +**Why this priority**: Used by multiple UI components for display, same priority as connectivity check. + +**Independent Test**: Can be fully tested by verifying cache usage and API call behavior. + +**Acceptance Scenarios**: + +1. **Given** device info exists in current state, **When** checkDeviceInfo is called, **Then** returns cached NodeDeviceInfo without API call. + +2. **Given** device info is null in current state, **When** checkDeviceInfo is called, **Then** makes API call and returns fresh NodeDeviceInfo. + +--- + +### User Story 4 - Provider Architecture Compliance (Priority: P1) + +The DashboardManagerNotifier maintains clean architecture by delegating all JNAP operations to DashboardManagerService. The Provider layer contains no JNAP model imports, action references, or result type handling. + +**Why this priority**: This is an architectural requirement that ensures maintainability and testability of the codebase. + +**Independent Test**: Can be verified by static analysis - Provider file should have zero imports from jnap/models, jnap/actions, or jnap/result directories. + +**Acceptance Scenarios**: + +1. **Given** the refactored Provider file, **When** checking imports, **Then** no imports from core/jnap/models/, core/jnap/actions/, or core/jnap/result/ exist. + +2. **Given** the refactored Provider file, **When** checking for RouterRepository usage, **Then** no direct RouterRepository access exists except through Service. + +--- + +### Edge Cases + +- What happens when polling data contains malformed JSON for individual actions? The service should skip that action and continue processing others. +- How does the system handle date/time parsing failures from getLocalTime? Should default to current device time. +- What happens when system stats fields (cpuLoad, memoryLoad) are missing? Should default to null values. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST create DashboardManagerService class that encapsulates all JNAP communication for dashboard data. + +- **FR-002**: DashboardManagerService MUST implement transformPollingData() method that converts CoreTransactionData to DashboardManagerState. + +- **FR-003**: DashboardManagerService MUST implement checkRouterIsBack() method that verifies router connectivity and serial number matching. + +- **FR-004**: DashboardManagerService MUST implement checkDeviceInfo() method that returns NodeDeviceInfo from cache or API. + +- **FR-005**: DashboardManagerService MUST handle the following JNAP actions: + - getDeviceInfo + - getRadioInfo + - getGuestRadioSettings + - getSystemStats + - getEthernetPortConnections + - getLocalTime + - getSoftSKUSettings + +- **FR-006**: DashboardManagerNotifier MUST be refactored to delegate all JNAP operations to DashboardManagerService. + +- **FR-007**: DashboardManagerNotifier MUST NOT contain any imports from jnap/models, jnap/actions, or jnap/result directories after refactoring. + +- **FR-008**: DashboardManagerService MUST convert JNAPError to ServiceError types for consistent error handling. + +- **FR-009**: Service MUST gracefully handle partial failures where some JNAP actions succeed and others fail. + +- **FR-010**: Service MUST provide a dashboardManagerServiceProvider for dependency injection via Riverpod. + +### Key Entities + +- **DashboardManagerService**: Stateless service class that handles all JNAP communication and data transformation for dashboard functionality. Injected with RouterRepository. + +- **DashboardManagerState**: Existing state class containing deviceInfo, mainRadios, guestRadios, isGuestNetworkEnabled, uptimes, wanConnection, lanConnections, skuModelNumber, localTime, cpuLoad, memoryLoad. + +- **CoreTransactionData**: Input from pollingProvider containing raw JNAP action results. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: DashboardManagerNotifier contains zero imports from jnap/models, jnap/actions, or jnap/result directories (verified by static analysis). + +- **SC-002**: All existing dashboard functionality continues to work correctly (no regression in user-visible behavior). + +- **SC-003**: DashboardManagerService has unit test coverage of at least 90% for all public methods. + +- **SC-004**: Provider test coverage maintains at least 85% after refactoring. + +- **SC-005**: Polling data transformation produces identical DashboardManagerState output before and after refactoring (verified by comparing state snapshots). + +## Assumptions + +- The existing DashboardManagerState class structure will be preserved; no changes to its fields or serialization. +- The saveSelectedNetwork() method in DashboardManagerNotifier does not involve JNAP calls and can remain in the Provider. +- The polling mechanism via pollingProvider remains unchanged; only the transformation logic moves to Service. +- NodeDeviceInfo and other JNAP models used in DashboardManagerState will continue to be valid return types from Service methods where they represent domain concepts (e.g., checkRouterIsBack returns NodeDeviceInfo). diff --git a/specs/005-dashboard-service-extraction/tasks.md b/specs/005-dashboard-service-extraction/tasks.md new file mode 100644 index 000000000..417c26786 --- /dev/null +++ b/specs/005-dashboard-service-extraction/tasks.md @@ -0,0 +1,319 @@ +# Tasks: Dashboard Manager Service Extraction + +**Input**: Design documents from `/specs/005-dashboard-service-extraction/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ ✓, quickstart.md ✓ + +**Tests**: Included per constitution.md Article I (Service ≥90%, Provider ≥85% coverage required) + +**Organization**: Tasks grouped by user story for independent implementation and testing. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) +- Include exact file paths in descriptions + +## Path Conventions + +```text +lib/core/jnap/ +├── providers/ +│ └── dashboard_manager_provider.dart # MODIFY +└── services/ + └── dashboard_manager_service.dart # CREATE + +test/core/jnap/ +├── providers/ +│ └── dashboard_manager_provider_test.dart # CREATE +├── services/ +│ └── dashboard_manager_service_test.dart # CREATE +└── test_data/ + └── dashboard_manager_test_data.dart # CREATE + +lib/core/errors/ +└── service_error.dart # MODIFY (if needed) +``` + +--- + +## Phase 1: Setup + +**Purpose**: Verify prerequisites and create file structure + +- [x] T001 Verify DeviceManagerService reference exists at `lib/core/jnap/services/device_manager_service.dart` +- [x] T002 [P] Verify test directory exists: `test/core/jnap/services/` (create if needed) +- [x] T003 [P] Verify test directory exists: `test/core/jnap/providers/` (create if needed) +- [x] T004 Check if `SerialNumberMismatchError` exists in `lib/core/errors/service_error.dart` +- [x] T005 Check if `ConnectivityError` exists in `lib/core/errors/service_error.dart` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add required ServiceError types before service implementation + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T006 Add `SerialNumberMismatchError` to `lib/core/errors/service_error.dart` (if not exists from T004) +- [x] T007 Add `ConnectivityError` to `lib/core/errors/service_error.dart` (if not exists from T005) + +**Checkpoint**: Foundation ready - user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Polling Data Transformation (Priority: P1) 🎯 MVP + +**Goal**: Transform dashboard polling data into UI state via Service without exposing JNAP details to Provider + +**Independent Test**: Provide mock CoreTransactionData → verify DashboardManagerState contains correctly transformed values + +### Tests for User Story 1 + +- [ ] T008 [P] [US1] Create test data builder class `DashboardManagerTestData` in `test/mocks/test_data/dashboard_manager_test_data.dart` +- [ ] T009 [P] [US1] Add `createDeviceInfoSuccess()` method to test data builder +- [ ] T010 [P] [US1] Add `createRadioInfoSuccess()` method to test data builder +- [ ] T011 [P] [US1] Add `createGuestRadioSettingsSuccess()` method to test data builder +- [ ] T012 [P] [US1] Add `createSystemStatsSuccess()` method to test data builder +- [ ] T013 [P] [US1] Add `createEthernetPortConnectionsSuccess()` method to test data builder +- [ ] T014 [P] [US1] Add `createLocalTimeSuccess()` method to test data builder +- [ ] T015 [P] [US1] Add `createSoftSKUSettingsSuccess()` method to test data builder +- [ ] T016 [US1] Add `createSuccessfulPollingData()` method combining all JNAP responses +- [ ] T017 [US1] Create service test file with transformPollingData test group in `test/core/jnap/services/dashboard_manager_service_test.dart` +- [ ] T018 [US1] Add test: transformPollingData returns default state when pollingResult is null +- [ ] T019 [US1] Add test: transformPollingData returns complete state when all actions succeed +- [ ] T020 [US1] Add test: transformPollingData returns partial state when some actions fail +- [ ] T021 [US1] Add test: transformPollingData correctly parses each JNAP action response +- [ ] T022 [US1] Add test: transformPollingData uses default localTime when parsing fails + +### Implementation for User Story 1 + +- [ ] T023 [US1] Create service file with provider definition in `lib/core/jnap/services/dashboard_manager_service.dart` +- [ ] T024 [US1] Create `DashboardManagerService` class with `RouterRepository` constructor injection +- [ ] T025 [US1] Implement `transformPollingData()` method - extract JNAP data from polling result +- [ ] T026 [US1] Move `_getMainRadioList()` helper from provider to service +- [ ] T027 [US1] Move `_getGuestRadioList()` helper from provider to service +- [ ] T028 [US1] Implement date/time parsing with fallback to current time +- [ ] T029 [US1] Implement system stats extraction (cpuLoad, memoryLoad, uptimes) +- [ ] T030 [US1] Implement ethernet port connections extraction (wanConnection, lanConnections) +- [ ] T031 [US1] Implement SoftSKU settings extraction (skuModelNumber) +- [ ] T032 [US1] Run service tests for transformPollingData: `flutter test test/core/jnap/services/dashboard_manager_service_test.dart` + +**Checkpoint**: transformPollingData() fully functional and tested + +--- + +## Phase 4: User Story 2 - Router Connectivity Check (Priority: P2) + +**Goal**: Verify router accessibility and serial number matching via Service + +**Independent Test**: Mock RouterRepository.send() → verify SN matching logic and error handling + +### Tests for User Story 2 + +- [ ] T033 [US2] Add checkRouterIsBack test group to `test/core/jnap/services/dashboard_manager_service_test.dart` +- [ ] T034 [US2] Add test: checkRouterIsBack returns NodeDeviceInfo when SN matches +- [ ] T035 [US2] Add test: checkRouterIsBack throws SerialNumberMismatchError when SN doesn't match +- [ ] T036 [US2] Add test: checkRouterIsBack throws ConnectivityError when router unreachable +- [ ] T037 [US2] Add test: checkRouterIsBack maps JNAPError to ServiceError correctly + +### Implementation for User Story 2 + +- [ ] T038 [US2] Implement `checkRouterIsBack(String expectedSerialNumber)` method in service +- [ ] T039 [US2] Implement `_mapJnapError()` helper for JNAPError → ServiceError conversion +- [ ] T040 [US2] Add serial number comparison logic with SerialNumberMismatchError +- [ ] T041 [US2] Add connectivity error handling with ConnectivityError +- [ ] T042 [US2] Run service tests for checkRouterIsBack: `flutter test test/core/jnap/services/dashboard_manager_service_test.dart` + +**Checkpoint**: checkRouterIsBack() fully functional and tested + +--- + +## Phase 5: User Story 3 - Device Info Retrieval (Priority: P2) + +**Goal**: Fetch device info with caching support via Service + +**Independent Test**: Verify cache usage when available, API call when cache is null + +### Tests for User Story 3 + +- [ ] T043 [US3] Add checkDeviceInfo test group to `test/core/jnap/services/dashboard_manager_service_test.dart` +- [ ] T044 [US3] Add test: checkDeviceInfo returns cached value immediately when available +- [ ] T045 [US3] Add test: checkDeviceInfo makes API call when cached value is null +- [ ] T046 [US3] Add test: checkDeviceInfo throws ServiceError on API failure + +### Implementation for User Story 3 + +- [ ] T047 [US3] Implement `checkDeviceInfo(NodeDeviceInfo? cachedDeviceInfo)` method in service +- [ ] T048 [US3] Add cache check logic - return immediately if cached value exists +- [ ] T049 [US3] Add API call with error handling for cache miss scenario +- [ ] T050 [US3] Run service tests for checkDeviceInfo: `flutter test test/core/jnap/services/dashboard_manager_service_test.dart` + +**Checkpoint**: checkDeviceInfo() fully functional and tested + +--- + +## Phase 6: User Story 4 - Provider Architecture Compliance (Priority: P1) + +**Goal**: Refactor Provider to delegate all JNAP operations to Service, removing JNAP imports + +**Independent Test**: Static analysis - Provider has zero imports from jnap/models, jnap/actions, jnap/result + +### Tests for User Story 4 + +- [ ] T051 [US4] Create provider test file in `test/core/jnap/providers/dashboard_manager_provider_test.dart` +- [ ] T052 [US4] Add test: build() delegates to service.transformPollingData() +- [ ] T053 [US4] Add test: checkRouterIsBack() delegates to service with correct SN +- [ ] T054 [US4] Add test: checkDeviceInfo() delegates to service with cached state + +### Implementation for User Story 4 + +- [ ] T055 [US4] Add service import to `lib/core/jnap/providers/dashboard_manager_provider.dart` +- [ ] T056 [US4] Refactor `build()` to delegate to `service.transformPollingData()` +- [ ] T057 [US4] Remove `createState()` method from provider (moved to service) +- [ ] T058 [US4] Remove `_getMainRadioList()` helper from provider (moved to service) +- [ ] T059 [US4] Remove `_getGuestRadioList()` helper from provider (moved to service) +- [ ] T060 [US4] Refactor `checkRouterIsBack()` to delegate to service +- [ ] T061 [US4] Refactor `checkDeviceInfo()` to delegate to service +- [ ] T062 [US4] Remove JNAP imports from provider: `jnap/actions/better_action.dart` +- [ ] T063 [US4] Remove JNAP imports from provider: `jnap/models/device_info.dart` +- [ ] T064 [US4] Remove JNAP imports from provider: `jnap/models/guest_radio_settings.dart` +- [ ] T065 [US4] Remove JNAP imports from provider: `jnap/models/radio_info.dart` +- [ ] T066 [US4] Remove JNAP imports from provider: `jnap/models/soft_sku_settings.dart` +- [ ] T067 [US4] Remove JNAP imports from provider: `jnap/result/jnap_result.dart` +- [ ] T068 [US4] Remove JNAP imports from provider: `jnap/router_repository.dart` +- [ ] T069 [US4] Keep `saveSelectedNetwork()` method unchanged in provider +- [ ] T070 [US4] Run provider tests: `flutter test test/core/jnap/providers/dashboard_manager_provider_test.dart` + +**Checkpoint**: Provider fully refactored with zero JNAP imports + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Verification, documentation, and final validation + +- [ ] T071 Run architecture compliance check: `grep -r "import.*jnap/models" lib/core/jnap/providers/dashboard_manager_provider.dart` (expect 0 results) +- [ ] T072 Run architecture compliance check: `grep -r "import.*jnap/result" lib/core/jnap/providers/dashboard_manager_provider.dart` (expect 0 results) +- [ ] T073 Run architecture compliance check: `grep -r "import.*jnap/actions" lib/core/jnap/providers/dashboard_manager_provider.dart` (expect 0 results) +- [ ] T074 Run `flutter analyze` and fix any issues in modified files +- [ ] T075 Run `dart format` on all modified files +- [ ] T076 Run all service tests: `flutter test test/core/jnap/services/dashboard_manager_service_test.dart` +- [ ] T077 Run all provider tests: `flutter test test/core/jnap/providers/dashboard_manager_provider_test.dart` +- [ ] T078 Run full test suite: `./run_tests.sh` +- [ ] T079 Verify test coverage meets requirements (Service ≥90%, Provider ≥85%) +- [ ] T080 Manual verification: Dashboard displays correctly after refactoring + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Phase 1: Setup ─────────────────────────────┐ + │ +Phase 2: Foundational ──────────────────────┼─── BLOCKS ALL USER STORIES + │ + ┌───────────────────────┘ + │ + ▼ + ┌───────────────────────┐ + │ Phase 3: US1 (P1) MVP │ ◄── transformPollingData + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Phase 4: US2 (P2) │ ◄── checkRouterIsBack + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Phase 5: US3 (P2) │ ◄── checkDeviceInfo + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Phase 6: US4 (P1) │ ◄── Provider refactoring + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Phase 7: Polish │ + └───────────────────────┘ +``` + +### User Story Dependencies + +| Story | Depends On | Can Run With | +|-------|------------|--------------| +| **US1** (transformPollingData) | Foundational | Independent | +| **US2** (checkRouterIsBack) | US1 (service file exists) | After US1 | +| **US3** (checkDeviceInfo) | US1 (service file exists) | After US1, parallel with US2 | +| **US4** (Provider refactoring) | US1, US2, US3 (all service methods) | After US1-3 | + +### Within Each User Story + +1. Test data builders first (parallel) +2. Tests defined (parallel) +3. Implementation +4. Run tests to verify + +### Parallel Opportunities + +**Phase 1**: T002, T003 can run in parallel +**Phase 3**: T008-T015 (test data builder methods) can run in parallel +**Phase 4-5**: US2 and US3 can potentially run in parallel after US1 +**Phase 6**: T062-T068 (import removals) can be done in single refactor step + +--- + +## Parallel Example: User Story 1 Test Data Builder + +```bash +# Launch all test data builder methods together: +Task: "Add createDeviceInfoSuccess() method" (T009) +Task: "Add createRadioInfoSuccess() method" (T010) +Task: "Add createGuestRadioSettingsSuccess() method" (T011) +Task: "Add createSystemStatsSuccess() method" (T012) +Task: "Add createEthernetPortConnectionsSuccess() method" (T013) +Task: "Add createLocalTimeSuccess() method" (T014) +Task: "Add createSoftSKUSettingsSuccess() method" (T015) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 + 4) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational +3. Complete Phase 3: US1 (transformPollingData) +4. Complete Phase 6: US4 (Provider refactoring for build() only) +5. **STOP and VALIDATE**: Dashboard displays correctly +6. Continue with US2, US3 for full feature + +### Recommended Order + +1. Setup + Foundational → Foundation ready +2. US1 (transformPollingData) → Core functionality +3. US2 (checkRouterIsBack) → Connectivity check +4. US3 (checkDeviceInfo) → Caching support +5. US4 (Provider refactoring) → Architecture compliance +6. Polish → Final validation + +### Quick Win Strategy + +For fastest working implementation: +1. T001-T007 (Setup + Foundational) +2. T023-T031 (US1 Implementation only) +3. T055-T069 (US4 Provider refactoring) +4. T071-T080 (Verification) + +Then backfill tests if time permits. + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story +- Each user story should be independently testable after completion +- Commit after each phase or logical group +- Reference: `lib/core/jnap/services/device_manager_service.dart` for implementation patterns diff --git a/specs/006-dashboard-home-service-extraction/checklists/requirements.md b/specs/006-dashboard-home-service-extraction/checklists/requirements.md new file mode 100644 index 000000000..b7a43fb83 --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: DashboardHome Service Extraction + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-29 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation +- Spec is ready for `/speckit.clarify` or `/speckit.plan` +- This is a pure refactoring task - no user-facing feature changes +- Key challenge: handling the `getBandConnectedBy` method dependency (documented in Assumptions) diff --git a/specs/006-dashboard-home-service-extraction/checklists/service-contract.md b/specs/006-dashboard-home-service-extraction/checklists/service-contract.md new file mode 100644 index 000000000..d25b7bc1c --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/checklists/service-contract.md @@ -0,0 +1,87 @@ +# Service Contract Requirements Quality Checklist + +**Purpose**: PR review gate for DashboardHomeService contract requirements +**Created**: 2025-12-29 +**Feature**: [spec.md](../spec.md) | [contract](../contracts/dashboard_home_service_contract.md) +**Focus**: Service Contract + Breaking Changes Risk + +--- + +## Requirement Completeness + +- [ ] CHK001 - Are all transformation behaviors from the original `createState()` method documented in the service contract? [Completeness, Spec §FR-002] +- [ ] CHK002 - Is the complete list of input parameters for `buildDashboardHomeState` specified with types and descriptions? [Completeness, Contract §Public Methods] +- [ ] CHK003 - Are all output fields of `DashboardHomeState` documented as being populated by the service? [Completeness, Gap] +- [ ] CHK004 - Is the `getBandForDevice` callback parameter requirement documented with its expected behavior? [Completeness, Contract §Parameters] +- [ ] CHK005 - Are all private helper methods documented with their input/output contracts? [Completeness, Contract §Private Methods] + +--- + +## Breaking Changes Prevention + +- [ ] CHK006 - Is the requirement for "identical behavior before and after refactoring" explicitly testable? [Measurability, Spec §FR-010] +- [ ] CHK007 - Are all current `DashboardHomeState` field values documented to ensure no regression? [Coverage, Gap] +- [ ] CHK008 - Is the WiFi list ordering requirement specified (must match original grouping logic)? [Clarity, Gap] +- [ ] CHK009 - Are connected device count calculation requirements specified to match original logic? [Clarity, Spec §FR-003] +- [ ] CHK010 - Is the guest network `isEnabled` flag propagation requirement documented? [Completeness, Gap] +- [ ] CHK011 - Are the `uptime`, `wanPortConnection`, `lanPortConnections` pass-through requirements specified? [Completeness, Gap] + +--- + +## Requirement Clarity + +- [ ] CHK012 - Is "groups main radios by band" clearly defined with the grouping key (`element.band`)? [Clarity, Contract §Behavior] +- [ ] CHK013 - Is the "first polling" detection logic (`lastUpdateTime == 0`) explicitly specified? [Clarity, Spec §Edge Cases] +- [ ] CHK014 - Are the `routerIconTestByModel` input requirements (modelNumber, hardwareVersion) documented? [Clarity, Contract §Dependencies] +- [ ] CHK015 - Are the `isHorizontalPorts` input requirements and fallback values documented? [Clarity, Contract §Dependencies] +- [ ] CHK016 - Is the callback signature `String Function(LinksysDevice)` unambiguous about return value semantics? [Clarity, Contract §Parameters] + +--- + +## Contract Consistency + +- [ ] CHK017 - Do the edge case behaviors in the contract align with the spec's edge case requirements? [Consistency, Spec §Edge Cases vs Contract §Error Handling] +- [ ] CHK018 - Is the service provider naming (`dashboardHomeServiceProvider`) consistent with constitution Article III? [Consistency, Constitution §3.4.1] +- [ ] CHK019 - Are the import requirements in the contract consistent with the architecture compliance goals in the spec? [Consistency, Spec §FR-007, FR-008] +- [ ] CHK020 - Does the contract's "no exceptions thrown" statement align with the pure transformation requirement? [Consistency, Contract §Error Handling] + +--- + +## Acceptance Criteria Quality + +- [ ] CHK021 - Can the "zero imports from `core/jnap/models/`" requirement be objectively verified with grep? [Measurability, Spec §SC-001] +- [ ] CHK022 - Is "identical behavior" defined with specific UI elements to verify (WiFi list, uptime, ports, nodes)? [Measurability, Spec §SC-002] +- [ ] CHK023 - Is the 90% test coverage requirement scoped to specific methods/classes? [Measurability, Spec §SC-003] +- [ ] CHK024 - Are the unit test requirements in the contract sufficient to verify all documented behaviors? [Coverage, Contract §Testing Contract] + +--- + +## Scenario Coverage + +- [ ] CHK025 - Are requirements defined for when `mainRadios` has multiple radios with the same band? [Coverage, Edge Case] +- [ ] CHK026 - Are requirements defined for when `deviceList` is empty (affects master icon)? [Coverage, Edge Case] +- [ ] CHK027 - Are requirements defined for partial data scenarios (some fields null in input states)? [Coverage, Exception Flow] +- [ ] CHK028 - Is the behavior specified when `getBandForDevice` callback returns an unexpected value? [Coverage, Exception Flow, Gap] + +--- + +## Dependencies & Assumptions + +- [ ] CHK029 - Is the assumption that `DashboardManagerState` and `DeviceManagerState` expose JNAP models validated? [Assumption, Spec §Assumptions] +- [ ] CHK030 - Are the utility function dependencies (`routerIconTestByModel`, `isHorizontalPorts`) validated as available? [Dependency, Contract §Dependencies] +- [ ] CHK031 - Is the `collection` package dependency for `groupFoldBy` documented in pubspec requirements? [Dependency, Gap] + +--- + +## Summary + +| Category | Item Count | +|----------|------------| +| Requirement Completeness | 5 | +| Breaking Changes Prevention | 6 | +| Requirement Clarity | 5 | +| Contract Consistency | 4 | +| Acceptance Criteria Quality | 4 | +| Scenario Coverage | 4 | +| Dependencies & Assumptions | 3 | +| **Total** | **31** | diff --git a/specs/006-dashboard-home-service-extraction/contracts/dashboard_home_service_contract.md b/specs/006-dashboard-home-service-extraction/contracts/dashboard_home_service_contract.md new file mode 100644 index 000000000..b18ba6986 --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/contracts/dashboard_home_service_contract.md @@ -0,0 +1,210 @@ +# Service Contract: DashboardHomeService + +**Feature**: 006-dashboard-home-service-extraction +**Date**: 2025-12-29 +**Location**: `lib/page/dashboard/services/dashboard_home_service.dart` + +--- + +## Overview + +`DashboardHomeService` is a stateless service responsible for transforming JNAP-layer state data into UI-layer `DashboardHomeState`. It encapsulates all JNAP model dependencies, keeping the Provider layer architecture-compliant. + +--- + +## Provider Definition + +```dart +/// Riverpod provider for DashboardHomeService +final dashboardHomeServiceProvider = Provider((ref) { + return DashboardHomeService(); +}); +``` + +--- + +## Class Definition + +```dart +/// Stateless service for dashboard home state transformation +/// +/// Encapsulates JNAP model transformations, separating data layer +/// concerns from state management (DashboardHomeNotifier). +class DashboardHomeService { + const DashboardHomeService(); + + // ... methods defined below +} +``` + +--- + +## Public Methods + +### buildDashboardHomeState + +Transforms JNAP-layer state data into a complete `DashboardHomeState`. + +**Signature**: +```dart +DashboardHomeState buildDashboardHomeState({ + required DashboardManagerState dashboardManagerState, + required DeviceManagerState deviceManagerState, + required String Function(LinksysDevice device) getBandForDevice, + required List deviceList, +}); +``` + +**Parameters**: + +| Parameter | Type | Description | +|-----------|------|-------------| +| dashboardManagerState | `DashboardManagerState` | State containing radio info, uptime, port connections | +| deviceManagerState | `DeviceManagerState` | State containing device list, WAN status, node info | +| getBandForDevice | `String Function(LinksysDevice)` | Callback to get band for a device (from DeviceManagerNotifier) | +| deviceList | `List` | Sorted device list for master icon determination | + +**Returns**: `DashboardHomeState` - Complete UI state for dashboard home + +**Example Usage**: +```dart +final service = ref.read(dashboardHomeServiceProvider); +final state = service.buildDashboardHomeState( + dashboardManagerState: ref.watch(dashboardManagerProvider), + deviceManagerState: ref.watch(deviceManagerProvider), + getBandForDevice: (device) => + ref.read(deviceManagerProvider.notifier).getBandConnectedBy(device), + deviceList: ref.read(deviceManagerProvider).deviceList, +); +``` + +**Behavior**: +1. Groups main radios by band and creates WiFi items with connected device counts +2. Creates guest WiFi item if guest radios exist +3. Determines node offline status +4. Extracts WAN type and detected WAN type +5. Determines if this is the first polling cycle +6. Gets master node icon based on model number +7. Determines port layout orientation + +--- + +## Private Methods (Internal Contract) + +These methods are implementation details but documented for completeness. + +### _buildMainWiFiItems + +```dart +List _buildMainWiFiItems({ + required List mainRadios, + required List mainWifiDevices, + required String Function(LinksysDevice) getBandForDevice, +}); +``` + +Groups radios by band and creates `DashboardWiFiUIModel` for each band. + +### _buildGuestWiFiItem + +```dart +DashboardWiFiUIModel? _buildGuestWiFiItem({ + required List guestRadios, + required List guestWifiDevices, + required bool isGuestNetworkEnabled, +}); +``` + +Creates guest network WiFi item if guest radios exist. + +### _createWiFiItemFromMainRadios + +```dart +DashboardWiFiUIModel _createWiFiItemFromMainRadios( + List radios, + int connectedDevices, +); +``` + +Creates `DashboardWiFiUIModel` from main radio list (replaces factory method). + +### _createWiFiItemFromGuestRadios + +```dart +DashboardWiFiUIModel _createWiFiItemFromGuestRadios( + List radios, + int connectedDevices, +); +``` + +Creates `DashboardWiFiUIModel` from guest radio list (replaces factory method). + +--- + +## Dependencies + +### Required Imports (Service Layer Only) + +```dart +// JNAP models (allowed in service layer) +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; + +// JNAP state (allowed in service layer) +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; + +// Utility functions +import 'package:privacy_gui/core/utils/icon_rules.dart'; +import 'package:privacy_gui/core/utils/nodes.dart'; + +// UI models (service returns these) +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +``` + +### External Dependencies + +| Dependency | Purpose | +|------------|---------| +| `collection` | `groupFoldBy` for radio grouping | +| `routerIconTestByModel()` | Icon determination utility | +| `isHorizontalPorts()` | Port layout determination utility | + +--- + +## Error Handling + +This service performs pure data transformation with no external I/O. No exceptions are thrown. + +**Edge Cases Handled**: +| Case | Behavior | +|------|----------| +| Empty mainRadios | Returns empty WiFi list | +| Empty guestRadios | Does not add guest WiFi item | +| Null deviceInfo | Uses default values for port layout | +| All nodes offline | Sets `isAnyNodesOffline = true` | +| First polling (lastUpdateTime == 0) | Sets `isFirstPolling = true` | + +--- + +## Testing Contract + +### Unit Test Requirements + +```dart +group('DashboardHomeService - buildDashboardHomeState', () { + test('returns correct state with main WiFi networks grouped by band', () {}); + test('returns correct state with guest WiFi when guest radios exist', () {}); + test('returns empty WiFi list when no radios exist', () {}); + test('correctly counts connected devices per band', () {}); + test('sets isAnyNodesOffline true when nodes are offline', () {}); + test('sets isFirstPolling true when lastUpdateTime is zero', () {}); + test('handles null deviceInfo for port layout', () {}); +}); +``` + +### Mock Requirements + +- Mock `DashboardManagerState` with various radio configurations +- Mock `DeviceManagerState` with various device states +- Mock `getBandForDevice` callback to return predictable bands diff --git a/specs/006-dashboard-home-service-extraction/data-model.md b/specs/006-dashboard-home-service-extraction/data-model.md new file mode 100644 index 000000000..27e65c628 --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/data-model.md @@ -0,0 +1,156 @@ +# Data Model: DashboardHome Service Extraction + +**Feature**: 006-dashboard-home-service-extraction +**Date**: 2025-12-29 + +## Overview + +This refactoring does not introduce new data models. It relocates transformation logic while preserving existing model structures. + +--- + +## Existing Models (Unchanged) + +### DashboardHomeState + +**Location**: `lib/page/dashboard/providers/dashboard_home_state.dart` +**Type**: UI State Model +**Changes**: Remove JNAP model imports only (structure unchanged) + +| Field | Type | Description | +|-------|------|-------------| +| isFirstPolling | `bool` | Whether this is the first data poll | +| isHorizontalLayout | `bool` | Port layout orientation | +| masterIcon | `String` | Router icon asset path | +| isAnyNodesOffline | `bool` | Whether any mesh nodes are offline | +| uptime | `int?` | Router uptime in seconds | +| wanPortConnection | `String?` | WAN port connection status | +| lanPortConnections | `List` | LAN port connection statuses | +| wifis | `List` | WiFi network list | +| wanType | `String?` | WAN connection type | +| detectedWANType | `String?` | Auto-detected WAN type | + +--- + +### DashboardWiFiUIModel + +**Location**: `lib/page/dashboard/providers/dashboard_home_state.dart` +**Type**: UI Model +**Changes**: Remove factory methods that accept JNAP models + +| Field | Type | Description | +|-------|------|-------------| +| ssid | `String` | Network name | +| password | `String` | Network password | +| radios | `List` | Radio IDs broadcasting this network | +| isGuest | `bool` | Whether this is a guest network | +| isEnabled | `bool` | Whether the network is enabled | +| numOfConnectedDevices | `int` | Count of connected devices | + +**Factory Methods to Remove**: +- `fromMainRadios(List, int)` → Move to service +- `fromGuestRadios(List, int)` → Move to service + +--- + +### DashboardSpeedUIModel + +**Location**: `lib/page/dashboard/providers/dashboard_home_state.dart` +**Type**: UI Model +**Changes**: None (no JNAP dependencies) + +| Field | Type | Description | +|-------|------|-------------| +| unit | `String` | Speed unit (Mbps, Gbps) | +| value | `String` | Speed value | + +--- + +## Input Models (From JNAP Layer - Read Only) + +These models are consumed by the service but defined in `core/jnap/`: + +### DashboardManagerState + +**Location**: `lib/core/jnap/providers/dashboard_manager_state.dart` + +| Field | Type | Used For | +|-------|------|----------| +| mainRadios | `List` | Building main WiFi items | +| guestRadios | `List` | Building guest WiFi item | +| isGuestNetworkEnabled | `bool` | Guest network enabled flag | +| uptimes | `int` | Router uptime | +| wanConnection | `String?` | WAN port status | +| lanConnections | `List` | LAN port statuses | +| deviceInfo | `NodeDeviceInfo?` | Port layout determination | + +### DeviceManagerState + +**Location**: `lib/core/jnap/providers/device_manager_state.dart` + +| Field | Type | Used For | +|-------|------|----------| +| mainWifiDevices | `List` | Counting main WiFi connections | +| guestWifiDevices | `List` | Counting guest WiFi connections | +| nodeDevices | `List` | Checking node offline status | +| wanStatus | `WANStatus?` | WAN type information | +| lastUpdateTime | `int` | First polling detection | +| deviceList | `List` | Master icon determination | + +--- + +## Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ INPUT (JNAP Layer) │ +├─────────────────────────────────────────────────────────────────┤ +│ DashboardManagerState DeviceManagerState HealthCheckState│ +│ - mainRadios - mainWifiDevices (unused) │ +│ - guestRadios - guestWifiDevices │ +│ - uptimes - nodeDevices │ +│ - wanConnection - wanStatus │ +│ - lanConnections - lastUpdateTime │ +│ - deviceInfo - deviceList │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DashboardHomeService │ +│ (NEW - Transformation) │ +├─────────────────────────────────────────────────────────────────┤ +│ buildDashboardHomeState(...) │ +│ ├── _buildMainWiFiItems() ← Groups radios by band │ +│ ├── _buildGuestWiFiItem() ← Creates guest network item │ +│ ├── _checkNodesOffline() ← Checks node status │ +│ ├── _getMasterIcon() ← Determines router icon │ +│ └── _getPortLayout() ← Determines port orientation │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ OUTPUT (UI Layer) │ +├─────────────────────────────────────────────────────────────────┤ +│ DashboardHomeState │ +│ - wifis: List │ +│ - uptime, wanPortConnection, lanPortConnections │ +│ - isFirstPolling, masterIcon, isAnyNodesOffline │ +│ - isHorizontalLayout, wanType, detectedWANType │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Validation Rules + +| Rule | Applies To | Validation | +|------|-----------|------------| +| Non-empty SSID | DashboardWiFiUIModel | Extracted from radio.settings.ssid (always present) | +| Valid radio list | DashboardWiFiUIModel | At least one radio required for main/guest | +| Non-negative device count | DashboardWiFiUIModel | Filtered count from device list | + +--- + +## State Transitions + +Not applicable - this is a pure transformation service with no state lifecycle. diff --git a/specs/006-dashboard-home-service-extraction/plan.md b/specs/006-dashboard-home-service-extraction/plan.md new file mode 100644 index 000000000..34ae6d380 --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/plan.md @@ -0,0 +1,81 @@ +# Implementation Plan: DashboardHome Service Extraction + +**Branch**: `006-dashboard-home-service-extraction` | **Date**: 2025-12-29 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/006-dashboard-home-service-extraction/spec.md` + +## Summary + +Extract data transformation logic from `DashboardHomeNotifier` into a new `DashboardHomeService` class to enforce three-layer architecture compliance. The service will handle all JNAP model transformations, allowing the Provider and State layers to remain free of `core/jnap/models/` imports. + +## Technical Context + +**Language/Version**: Dart 3.0+, Flutter 3.3+ +**Primary Dependencies**: flutter_riverpod 2.6.1, equatable 2.0.5, collection +**Storage**: N/A (state management only) +**Testing**: flutter_test, mocktail +**Target Platform**: iOS, Android, Web (Flutter multi-platform) +**Project Type**: Mobile application +**Performance Goals**: N/A (pure refactoring, no behavior change) +**Constraints**: Must maintain identical runtime behavior; Provider layer must not import `core/jnap/models/` +**Scale/Scope**: Single feature refactoring (~100 lines of code moved) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Article | Requirement | Status | Notes | +|---------|-------------|--------|-------| +| **Article I** | Test coverage for Services ≥90%, Providers ≥85% | ✅ PASS | Will create unit tests for DashboardHomeService | +| **Article III** | Naming conventions (snake_case files, UpperCamelCase classes) | ✅ PASS | `dashboard_home_service.dart`, `DashboardHomeService` | +| **Article V** | Three-layer architecture compliance | ✅ PASS | This refactoring enforces Article V compliance | +| **Article VI** | Service layer for business logic & JNAP communication | ✅ PASS | Creating DashboardHomeService per Article VI | +| **Article VII** | No unnecessary abstractions | ✅ PASS | Service is legitimate abstraction per Article VII Section 7.2 | +| **Article VIII** | Unit tests with Mocktail | ✅ PASS | Will use Mocktail for service tests | +| **Article IX** | API contracts in Markdown | ✅ PASS | contracts/dashboard_home_service_contract.md | +| **Article XI** | Models implement Equatable, toMap/fromMap | ✅ PASS | Existing models already compliant | +| **Article XIII** | ServiceError for error handling | ⚠️ N/A | No JNAP calls in service (pure transformation) | + +**Gate Status**: ✅ PASSED - All applicable articles satisfied + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-dashboard-home-service-extraction/ +├── spec.md # Feature specification +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── dashboard_home_service_contract.md +└── tasks.md # Phase 2 output (created by /speckit.tasks) +``` + +### Source Code (repository root) + +```text +lib/page/dashboard/ +├── providers/ +│ ├── dashboard_home_provider.dart # Refactored - delegates to service +│ └── dashboard_home_state.dart # Refactored - no JNAP imports +├── services/ # NEW directory +│ └── dashboard_home_service.dart # NEW - transformation logic +└── views/ + └── [unchanged] + +test/page/dashboard/ +├── providers/ +│ └── dashboard_home_provider_test.dart # Update/create +├── services/ # NEW directory +│ └── dashboard_home_service_test.dart # NEW - service tests +└── mocks/test_data/ + └── dashboard_home_test_data.dart # NEW - test data builder +``` + +**Structure Decision**: Flutter mobile app structure with three-layer architecture (views → providers → services). New `services/` directory created under `lib/page/dashboard/`. + +## Complexity Tracking + +No violations to justify - this implementation follows standard patterns. diff --git a/specs/006-dashboard-home-service-extraction/quickstart.md b/specs/006-dashboard-home-service-extraction/quickstart.md new file mode 100644 index 000000000..6175a7646 --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/quickstart.md @@ -0,0 +1,190 @@ +# Quickstart: DashboardHome Service Extraction + +**Feature**: 006-dashboard-home-service-extraction +**Date**: 2025-12-29 + +--- + +## Overview + +This guide provides step-by-step instructions for implementing the DashboardHomeService extraction refactoring. + +--- + +## Prerequisites + +- Branch: `006-dashboard-home-service-extraction` +- Flutter SDK installed +- Dependencies installed (`flutter pub get`) + +--- + +## Implementation Steps + +### Step 1: Create Service File + +Create `lib/page/dashboard/services/dashboard_home_service.dart`: + +```dart +import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/core/utils/icon_rules.dart'; +import 'package:privacy_gui/core/utils/nodes.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; + +final dashboardHomeServiceProvider = Provider((ref) { + return const DashboardHomeService(); +}); + +class DashboardHomeService { + const DashboardHomeService(); + + DashboardHomeState buildDashboardHomeState({ + required DashboardManagerState dashboardManagerState, + required DeviceManagerState deviceManagerState, + required String Function(LinksysDevice device) getBandForDevice, + required List deviceList, + }) { + // Implementation here - move logic from DashboardHomeNotifier.createState() + } +} +``` + +### Step 2: Refactor Provider + +Update `lib/page/dashboard/providers/dashboard_home_provider.dart`: + +```dart +// REMOVE these imports: +// import 'package:privacy_gui/core/jnap/models/radio_info.dart'; + +// ADD service import: +import 'package:privacy_gui/page/dashboard/services/dashboard_home_service.dart'; + +class DashboardHomeNotifier extends Notifier { + @override + DashboardHomeState build() { + final service = ref.read(dashboardHomeServiceProvider); + final dashboardManagerState = ref.watch(dashboardManagerProvider); + final deviceManagerState = ref.watch(deviceManagerProvider); + + return service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: (device) => + ref.read(deviceManagerProvider.notifier).getBandConnectedBy(device), + deviceList: ref.read(deviceManagerProvider).deviceList, + ); + } +} +``` + +### Step 3: Refactor State File + +Update `lib/page/dashboard/providers/dashboard_home_state.dart`: + +```dart +// REMOVE these imports: +// import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +// import 'package:privacy_gui/core/jnap/models/radio_info.dart'; + +// REMOVE these factory methods from DashboardWiFiUIModel: +// factory DashboardWiFiUIModel.fromMainRadios(...) +// factory DashboardWiFiUIModel.fromGuestRadios(...) +``` + +### Step 4: Create Test Data Builder + +Create `test/mocks/test_data/dashboard_home_test_data.dart`: + +```dart +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; + +class DashboardHomeTestData { + static DashboardManagerState createDashboardManagerState({ + // Parameters with defaults + }) { + // Return mock state + } + + static DeviceManagerState createDeviceManagerState({ + // Parameters with defaults + }) { + // Return mock state + } +} +``` + +### Step 5: Create Service Tests + +Create `test/page/dashboard/services/dashboard_home_service_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/dashboard/services/dashboard_home_service.dart'; +import 'package:test/mocks/test_data/dashboard_home_test_data.dart'; + +void main() { + late DashboardHomeService service; + + setUp(() { + service = const DashboardHomeService(); + }); + + group('DashboardHomeService - buildDashboardHomeState', () { + test('returns correct state with main WiFi networks', () { + // Test implementation + }); + }); +} +``` + +--- + +## Verification Commands + +```bash +# Run tests +flutter test test/page/dashboard/ + +# Check architecture compliance +grep -r "import.*jnap/models" lib/page/dashboard/providers/ +# Should return 0 results + +# Run static analysis +flutter analyze lib/page/dashboard/ +``` + +--- + +## File Checklist + +| File | Action | +|------|--------| +| `lib/page/dashboard/services/dashboard_home_service.dart` | CREATE | +| `lib/page/dashboard/providers/dashboard_home_provider.dart` | MODIFY | +| `lib/page/dashboard/providers/dashboard_home_state.dart` | MODIFY | +| `test/page/dashboard/services/dashboard_home_service_test.dart` | CREATE | +| `test/mocks/test_data/dashboard_home_test_data.dart` | CREATE | + +--- + +## Common Issues + +### Issue: `getBandConnectedBy` not accessible + +**Solution**: Pass as callback function to service method. + +### Issue: Circular import + +**Solution**: Ensure service only imports from `core/` and returns types from `providers/`. + +### Issue: Test coverage below 90% + +**Solution**: Add tests for edge cases (empty lists, null values, offline nodes). diff --git a/specs/006-dashboard-home-service-extraction/research.md b/specs/006-dashboard-home-service-extraction/research.md new file mode 100644 index 000000000..f685dcfcf --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/research.md @@ -0,0 +1,118 @@ +# Research: DashboardHome Service Extraction + +**Feature**: 006-dashboard-home-service-extraction +**Date**: 2025-12-29 + +## Research Summary + +This is a straightforward refactoring task with no unknowns requiring deep research. All patterns are already established in the codebase. + +--- + +## Decision 1: Service Pattern for Pure Transformation + +**Decision**: Use stateless service class with pure transformation methods (no RouterRepository dependency) + +**Rationale**: +- `DashboardHomeNotifier` does NOT call JNAP directly +- It transforms data from other providers (`dashboardManagerProvider`, `deviceManagerProvider`) +- The service only needs to perform data transformation, not API calls +- This differs from services like `RouterPasswordService` which interact with `RouterRepository` + +**Alternatives Considered**: +| Alternative | Rejected Because | +|------------|------------------| +| Service with RouterRepository injection | Unnecessary - no JNAP calls needed | +| Static utility class | Less testable, doesn't follow project patterns | +| Extension methods on state classes | Doesn't isolate JNAP model dependencies | + +--- + +## Decision 2: Handling `getBandConnectedBy` Dependency + +**Decision**: Pass band information as a callback function to the service method + +**Rationale**: +- Current code calls `ref.read(deviceManagerProvider.notifier).getBandConnectedBy(device)` +- Service layer cannot access Riverpod `ref` directly (violates stateless principle) +- Callback pattern keeps service pure and testable + +**Implementation Pattern**: +```dart +// Service signature +DashboardHomeState buildState({ + required DashboardManagerState dashboardState, + required DeviceManagerState deviceState, + required String Function(LinksysDevice) getBandForDevice, + required List deviceList, +}); + +// Provider usage +final state = service.buildState( + dashboardState: dashboardManagerState, + deviceState: deviceManagerState, + getBandForDevice: (device) => ref.read(deviceManagerProvider.notifier).getBandConnectedBy(device), + deviceList: ref.read(deviceManagerProvider).deviceList, +); +``` + +**Alternatives Considered**: +| Alternative | Rejected Because | +|------------|------------------| +| Pass Ref to service | Violates stateless principle, tight coupling | +| Pre-compute all bands in provider | Complex, duplicates logic | +| Inject DeviceManagerNotifier | Creates circular dependency risk | + +--- + +## Decision 3: Factory Method Relocation + +**Decision**: Remove `DashboardWiFiUIModel.fromMainRadios()` and `fromGuestRadios()` factories, replace with private service methods + +**Rationale**: +- Factory methods on UI model classes create JNAP model dependencies +- Moving logic to service isolates the dependency +- Private methods prevent external misuse + +**Implementation Pattern**: +```dart +// In DashboardHomeService +DashboardWiFiUIModel _buildMainWiFiItem(List radios, int connectedDevices) { + final radio = radios.first; + return DashboardWiFiUIModel( + ssid: radio.settings.ssid, + password: radio.settings.wpaPersonalSettings?.passphrase ?? '', + radios: radios.map((e) => e.radioID).toList(), + isGuest: false, + isEnabled: radio.settings.isEnabled, + numOfConnectedDevices: connectedDevices, + ); +} +``` + +--- + +## Decision 4: Test Data Builder Location + +**Decision**: Create `test/mocks/test_data/dashboard_home_test_data.dart` + +**Rationale**: +- Follows constitution Article I Section 1.6.2 +- Provides reusable mock data for DashboardManagerState, DeviceManagerState +- Centralizes test data creation + +--- + +## Reference Implementations + +| Pattern | Reference File | Relevance | +|---------|---------------|-----------| +| Service with Provider injection | `lib/page/instant_admin/services/router_password_service.dart` | Service provider pattern | +| Transformation service | `lib/page/dashboard/services/dashboard_manager_service.dart` | Similar transformation logic | +| Test data builder | `test/mocks/test_data/` | Test data organization | + +--- + +## No Further Research Needed + +All technical decisions are resolved. Proceed to Phase 1 design. diff --git a/specs/006-dashboard-home-service-extraction/spec.md b/specs/006-dashboard-home-service-extraction/spec.md new file mode 100644 index 000000000..4621fd9b9 --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/spec.md @@ -0,0 +1,87 @@ +# Feature Specification: DashboardHome Service Extraction + +**Feature Branch**: `006-dashboard-home-service-extraction` +**Created**: 2025-12-29 +**Status**: Draft +**Input**: User description: "Extract DashboardHomeService from DashboardHomeNotifier to enforce three-layer architecture compliance." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Architecture Compliance Refactoring (Priority: P1) + +As a developer maintaining the dashboard home feature, I need the code to follow the three-layer architecture so that JNAP model dependencies are properly isolated in the service layer and the codebase remains maintainable and testable. + +**Why this priority**: This is a pure refactoring task with a single goal - architectural compliance. The entire feature is about moving data transformation logic from Provider layer to Service layer without changing any user-facing behavior. + +**Independent Test**: Can be fully tested by verifying that Provider and State files no longer import JNAP models, while all existing dashboard functionality continues to work correctly. + +**Acceptance Scenarios**: + +1. **Given** the refactored codebase, **When** running `grep -r "import.*jnap/models" lib/page/dashboard/providers/`, **Then** zero results are returned +2. **Given** the refactored codebase, **When** the dashboard home view loads, **Then** all WiFi networks, uptime, port connections, and node status display correctly as before +3. **Given** the new DashboardHomeService, **When** unit tests run, **Then** JNAP model to UI model transformations are verified independently + +--- + +### User Story 2 - Service Layer Testability (Priority: P2) + +As a developer writing tests, I need the data transformation logic isolated in a service class so that I can unit test the WiFi item creation and state building logic without mocking the entire provider hierarchy. + +**Why this priority**: Testability is a key benefit of the service extraction but secondary to the primary architectural compliance goal. + +**Independent Test**: Can be tested by creating unit tests for DashboardHomeService that mock only RouterRepository-level data and verify correct UI model output. + +**Acceptance Scenarios**: + +1. **Given** DashboardHomeService with mocked input data, **When** building WiFi items from main radios, **Then** correct DashboardWiFiUIModel is returned with proper SSID, password, radios list, and device count +2. **Given** DashboardHomeService with mocked input data, **When** building WiFi items from guest radios, **Then** correct DashboardWiFiUIModel is returned with isGuest=true and proper guest network properties + +--- + +### Edge Cases + +- What happens when mainRadios list is empty? Service should return empty WiFi list without errors +- What happens when guestRadios list is empty? Service should not add guest WiFi item to the list +- What happens when all nodes are offline? isAnyNodesOffline flag should be true +- What happens when deviceInfo is null? Service should handle null safely for port layout determination + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST create a new DashboardHomeService class in `lib/page/dashboard/services/` +- **FR-002**: DashboardHomeService MUST contain all data transformation logic currently in DashboardHomeNotifier.createState() +- **FR-003**: DashboardHomeService MUST handle WiFi list building from main radios (grouping by band, counting connected devices) +- **FR-004**: DashboardHomeService MUST handle WiFi list building from guest radios +- **FR-005**: DashboardHomeService MUST determine node offline status, WAN type, port layout, and master icon +- **FR-006**: DashboardHomeNotifier MUST delegate to DashboardHomeService for state building +- **FR-007**: DashboardHomeNotifier MUST NOT import any `core/jnap/models/` files after refactoring +- **FR-008**: DashboardHomeState file MUST NOT import any `core/jnap/models/` files after refactoring +- **FR-009**: DashboardWiFiUIModel factory methods (fromMainRadios, fromGuestRadios) MUST be removed or moved to Service layer +- **FR-010**: System MUST maintain identical behavior and output as before refactoring (pure refactor, no feature changes) + +### Key Entities + +- **DashboardHomeService**: New service class responsible for transforming JNAP/Manager state data into DashboardHomeState +- **DashboardHomeState**: Existing UI state model (unchanged structure, but file must not import JNAP models) +- **DashboardWiFiUIModel**: Existing UI model for WiFi network display (factory methods relocated to service) +- **DashboardManagerState**: Input data source containing radio info, uptime, port connections (JNAP layer) +- **DeviceManagerState**: Input data source containing device list, WAN status, node info (JNAP layer) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Provider layer (`lib/page/dashboard/providers/`) contains zero imports from `core/jnap/models/` +- **SC-002**: All existing dashboard home functionality works identically before and after refactoring +- **SC-003**: DashboardHomeService has unit test coverage of at least 90% for data transformation methods +- **SC-004**: Code passes `flutter analyze` with no new warnings or errors +- **SC-005**: Architecture compliance check script returns zero violations for dashboard providers + +## Assumptions + +- The existing DashboardManagerState and DeviceManagerState will continue to expose JNAP models (they are in the core/jnap layer, not page layer) +- DashboardHomeService will receive these manager states as input and transform them to UI models +- The service will be stateless (pure transformation functions) +- Helper functions like `routerIconTestByModel` and `isHorizontalPorts` can continue to be used by the service +- The `getBandConnectedBy` method from DeviceManagerNotifier will need to be accessible to the service layer (may require interface or callback pattern) diff --git a/specs/006-dashboard-home-service-extraction/tasks.md b/specs/006-dashboard-home-service-extraction/tasks.md new file mode 100644 index 000000000..7ef240add --- /dev/null +++ b/specs/006-dashboard-home-service-extraction/tasks.md @@ -0,0 +1,236 @@ +# Tasks: DashboardHome Service Extraction + +**Input**: Design documents from `/specs/006-dashboard-home-service-extraction/` +**Prerequisites**: plan.md, spec.md, data-model.md, contracts/dashboard_home_service_contract.md + +**Tests**: Tests are REQUIRED per spec.md (SC-003: ≥90% coverage for DashboardHomeService) + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2) +- Include exact file paths in descriptions + +## Path Conventions + +- **Source**: `lib/page/dashboard/` +- **Tests**: `test/page/dashboard/` +- **Test Data**: `test/mocks/test_data/` + +--- + +## Phase 1: Setup + +**Purpose**: Create directory structure and test infrastructure + +- [ ] T001 Create services directory at `lib/page/dashboard/services/` +- [ ] T002 [P] Create test services directory at `test/page/dashboard/services/` + +**Checkpoint**: Directory structure ready for implementation + +--- + +## Phase 2: Foundational (Test Data Builder) + +**Purpose**: Create test infrastructure required by both user stories + +**⚠️ CRITICAL**: Test data builder is needed before any tests can be written + +- [ ] T003 Create DashboardHomeTestData class in `test/mocks/test_data/dashboard_home_test_data.dart` with: + - `createDashboardManagerState()` factory for mock DashboardManagerState + - `createDeviceManagerState()` factory for mock DeviceManagerState + - `createRouterRadio()` helper for radio configurations + - `createGuestRadioInfo()` helper for guest radio configurations + - `createLinksysDevice()` helper for device mocks + +**Checkpoint**: Test data builder ready - user story implementation can now begin + +--- + +## Phase 3: User Story 1 - Architecture Compliance Refactoring (Priority: P1) 🎯 MVP + +**Goal**: Extract transformation logic to DashboardHomeService, remove JNAP model imports from Provider layer + +**Independent Test**: `grep -r "import.*jnap/models" lib/page/dashboard/providers/` returns zero results + +### Tests for User Story 1 + +> **NOTE: Write these tests FIRST, ensure they FAIL before implementation** + +- [ ] T004 [P] [US1] Create service test file `test/page/dashboard/services/dashboard_home_service_test.dart` with test group structure +- [ ] T005 [P] [US1] Add test: 'returns correct state with main WiFi networks grouped by band' +- [ ] T006 [P] [US1] Add test: 'returns correct state with guest WiFi when guest radios exist' +- [ ] T007 [P] [US1] Add test: 'returns empty WiFi list when no radios exist' +- [ ] T008 [P] [US1] Add test: 'sets isAnyNodesOffline true when nodes are offline' +- [ ] T009 [P] [US1] Add test: 'sets isFirstPolling true when lastUpdateTime is zero' +- [ ] T010 [P] [US1] Add test: 'handles null deviceInfo for port layout' + +### Implementation for User Story 1 + +- [ ] T011 [US1] Create DashboardHomeService class in `lib/page/dashboard/services/dashboard_home_service.dart` with: + - `dashboardHomeServiceProvider` provider definition + - `DashboardHomeService` class with const constructor + - `buildDashboardHomeState()` public method signature + - Required imports from JNAP models and utilities + +- [ ] T012 [US1] Implement `_buildMainWiFiItems()` private method in `lib/page/dashboard/services/dashboard_home_service.dart`: + - Group radios by band using `groupFoldBy` + - Count connected devices per band + - Create DashboardWiFiUIModel for each band group + +- [ ] T013 [US1] Implement `_buildGuestWiFiItem()` private method in `lib/page/dashboard/services/dashboard_home_service.dart`: + - Check if guest radios exist + - Count online guest devices + - Create DashboardWiFiUIModel with isGuest=true + +- [ ] T014 [US1] Implement `_createWiFiItemFromMainRadios()` private method in `lib/page/dashboard/services/dashboard_home_service.dart`: + - Extract SSID, password, radioIDs from RouterRadio list + - Replace DashboardWiFiUIModel.fromMainRadios() factory logic + +- [ ] T015 [US1] Implement `_createWiFiItemFromGuestRadios()` private method in `lib/page/dashboard/services/dashboard_home_service.dart`: + - Extract guest SSID, password, radioIDs from GuestRadioInfo list + - Replace DashboardWiFiUIModel.fromGuestRadios() factory logic + +- [ ] T016 [US1] Complete `buildDashboardHomeState()` implementation in `lib/page/dashboard/services/dashboard_home_service.dart`: + - Call _buildMainWiFiItems() and _buildGuestWiFiItem() + - Determine isAnyNodesOffline from nodeDevices + - Extract WAN type and detected WAN type + - Determine isFirstPolling from lastUpdateTime + - Get master icon using routerIconTestByModel() + - Get port layout using isHorizontalPorts() + - Return complete DashboardHomeState + +- [ ] T017 [US1] Refactor DashboardHomeNotifier in `lib/page/dashboard/providers/dashboard_home_provider.dart`: + - Add import for DashboardHomeService + - Remove import for `core/jnap/models/radio_info.dart` + - Update build() to use service.buildDashboardHomeState() + - Remove createState() method (logic moved to service) + +- [ ] T018 [US1] Refactor DashboardHomeState in `lib/page/dashboard/providers/dashboard_home_state.dart`: + - Remove import for `core/jnap/models/guest_radio_settings.dart` + - Remove import for `core/jnap/models/radio_info.dart` + - Remove DashboardWiFiUIModel.fromMainRadios() factory method + - Remove DashboardWiFiUIModel.fromGuestRadios() factory method + +- [ ] T019 [US1] Run architecture compliance check: + - Execute `grep -r "import.*jnap/models" lib/page/dashboard/providers/` + - Verify zero results returned + +**Checkpoint**: User Story 1 complete - Architecture compliant, all tests passing + +--- + +## Phase 4: User Story 2 - Service Layer Testability (Priority: P2) + +**Goal**: Complete test coverage for edge cases and transformation logic + +**Independent Test**: Run `flutter test test/page/dashboard/services/` - all tests pass with ≥90% coverage + +### Tests for User Story 2 + +- [ ] T020 [P] [US2] Add test: 'correctly counts connected devices per band' in `test/page/dashboard/services/dashboard_home_service_test.dart` +- [ ] T021 [P] [US2] Add test: 'does not add guest WiFi when guest network disabled' in `test/page/dashboard/services/dashboard_home_service_test.dart` +- [ ] T022 [P] [US2] Add test: 'correctly extracts WAN type from wanStatus' in `test/page/dashboard/services/dashboard_home_service_test.dart` +- [ ] T023 [P] [US2] Add test: 'correctly extracts detectedWANType from wanStatus' in `test/page/dashboard/services/dashboard_home_service_test.dart` +- [ ] T024 [P] [US2] Add test: 'correctly determines master icon from deviceList' in `test/page/dashboard/services/dashboard_home_service_test.dart` +- [ ] T025 [P] [US2] Add test: 'correctly determines horizontal port layout' in `test/page/dashboard/services/dashboard_home_service_test.dart` +- [ ] T026 [P] [US2] Add test: 'passes through uptime, wanConnection, lanConnections correctly' in `test/page/dashboard/services/dashboard_home_service_test.dart` + +### Implementation for User Story 2 + +- [ ] T027 [US2] Add edge case handling to DashboardHomeService if any test reveals missing logic in `lib/page/dashboard/services/dashboard_home_service.dart` +- [ ] T028 [US2] Run test coverage check: `flutter test --coverage test/page/dashboard/services/` +- [ ] T029 [US2] Verify ≥90% coverage for DashboardHomeService methods + +**Checkpoint**: User Story 2 complete - Full test coverage achieved + +--- + +## Phase 5: Polish & Verification + +**Purpose**: Final validation and cleanup + +- [ ] T030 Run `flutter analyze lib/page/dashboard/` - verify no new warnings +- [ ] T031 Run `dart format lib/page/dashboard/` - apply formatting +- [ ] T032 Run `dart format test/page/dashboard/` - apply formatting to tests +- [ ] T033 Run full test suite: `flutter test test/page/dashboard/` +- [ ] T034 Manual verification: Launch app and verify dashboard home displays correctly + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational phase completion +- **User Story 2 (Phase 4)**: Can start after Phase 3 tests are written (T004-T010) +- **Polish (Phase 5)**: Depends on all user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: No dependencies on other stories - this is the core refactoring +- **User Story 2 (P2)**: Extends US1 test coverage - requires US1 implementation complete + +### Within Each User Story + +- Tests MUST be written and FAIL before implementation +- Service methods before provider refactoring +- Provider refactoring before state refactoring +- Architecture check after all refactoring complete + +### Parallel Opportunities + +- T001-T002: Can run in parallel (different directories) +- T004-T010: All US1 tests can be written in parallel +- T011-T016: Service implementation is sequential (method dependencies) +- T017-T018: Provider and State refactoring can run in parallel +- T020-T026: All US2 tests can be written in parallel + +--- + +## Parallel Example: User Story 1 Tests + +```bash +# Launch all US1 tests in parallel: +Task: T004 "Create service test file structure" +Task: T005 "Add test: main WiFi networks grouped by band" +Task: T006 "Add test: guest WiFi when guest radios exist" +Task: T007 "Add test: empty WiFi list" +Task: T008 "Add test: isAnyNodesOffline" +Task: T009 "Add test: isFirstPolling" +Task: T010 "Add test: null deviceInfo" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001-T002) +2. Complete Phase 2: Foundational (T003) +3. Complete Phase 3: User Story 1 (T004-T019) +4. **STOP and VALIDATE**: Run architecture compliance check +5. Core refactoring complete - dashboard functions correctly + +### Incremental Delivery + +1. Setup + Foundational → Infrastructure ready +2. Add User Story 1 → Architecture compliant → **MVP Complete** +3. Add User Story 2 → Full test coverage → Production ready +4. Polish → Code quality verified + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [US1] = Architecture Compliance Refactoring (core goal) +- [US2] = Service Layer Testability (extended testing) +- Tests must fail before implementation begins +- Commit after each logical task group +- Stop at any checkpoint to validate independently diff --git a/test/core/jnap/providers/dashboard_manager_provider_test.dart b/test/core/jnap/providers/dashboard_manager_provider_test.dart new file mode 100644 index 000000000..c9619ed58 --- /dev/null +++ b/test/core/jnap/providers/dashboard_manager_provider_test.dart @@ -0,0 +1,323 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_provider.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/services/dashboard_manager_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../mocks/test_data/dashboard_manager_test_data.dart'; + +class MockDashboardManagerService extends Mock + implements DashboardManagerService {} + +void main() { + // Initialize Flutter binding for SharedPreferences + TestWidgetsFlutterBinding.ensureInitialized(); + late MockDashboardManagerService mockService; + late ProviderContainer container; + + setUp(() { + mockService = MockDashboardManagerService(); + }); + + tearDown(() { + container.dispose(); + }); + + group('DashboardManagerNotifier - build', () { + test('delegates to service.transformPollingData with polling data', () { + // Arrange + final pollingData = + DashboardManagerTestData.createSuccessfulPollingData(); + final expectedState = DashboardManagerState( + deviceInfo: NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess().output, + ), + uptimes: 86400, + wanConnection: 'Linked-1000Mbps', + ); + + when(() => mockService.transformPollingData(pollingData)) + .thenReturn(expectedState); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(pollingData)), + ], + ); + + // Act + final state = container.read(dashboardManagerProvider); + + // Assert + verify(() => mockService.transformPollingData(pollingData)).called(1); + expect(state, equals(expectedState)); + }); + + test('handles null polling data by returning default state', () { + // Arrange + const expectedState = DashboardManagerState(); + + when(() => mockService.transformPollingData(any())) + .thenReturn(expectedState); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final state = container.read(dashboardManagerProvider); + + // Assert + verify(() => mockService.transformPollingData(any())).called(1); + expect(state.deviceInfo, isNull); + expect(state.mainRadios, isEmpty); + expect(state.uptimes, equals(0)); + }); + + test('rebuilds when polling data changes', () async { + // Arrange + final pollingData1 = DashboardManagerTestData.createSuccessfulPollingData( + systemStats: DashboardManagerTestData.createSystemStatsSuccess( + uptimeSeconds: 1000, + ), + ); + final pollingData2 = DashboardManagerTestData.createSuccessfulPollingData( + systemStats: DashboardManagerTestData.createSystemStatsSuccess( + uptimeSeconds: 2000, + ), + ); + + const state1 = DashboardManagerState(uptimes: 1000); + const state2 = DashboardManagerState(uptimes: 2000); + + when(() => mockService.transformPollingData(pollingData1)) + .thenReturn(state1); + when(() => mockService.transformPollingData(pollingData2)) + .thenReturn(state2); + + // First container with pollingData1 + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider + .overrideWith(() => _MockPollingNotifier(pollingData1)), + ], + ); + + // Act - First read + final firstState = container.read(dashboardManagerProvider); + expect(firstState.uptimes, equals(1000)); + + // Dispose and create new container with pollingData2 + container.dispose(); + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider + .overrideWith(() => _MockPollingNotifier(pollingData2)), + ], + ); + + final secondState = container.read(dashboardManagerProvider); + + // Assert + expect(secondState.uptimes, equals(2000)); + }); + }); + + group('DashboardManagerNotifier - checkRouterIsBack', () { + setUp(() { + // Mock SharedPreferences for checkRouterIsBack tests + SharedPreferences.setMockInitialValues({ + 'currentSN': 'MOCK_SN', + }); + }); + + test('delegates to service.checkRouterIsBack', () async { + // Arrange + final expectedDeviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'TEST_SN') + .output, + ); + + when(() => mockService.transformPollingData(any())) + .thenReturn(const DashboardManagerState()); + when(() => mockService.checkRouterIsBack(any())) + .thenAnswer((_) async => expectedDeviceInfo); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final result = await container + .read(dashboardManagerProvider.notifier) + .checkRouterIsBack(); + + // Assert + verify(() => mockService.checkRouterIsBack(any())).called(1); + expect(result.serialNumber, equals('TEST_SN')); + }); + + test('propagates SerialNumberMismatchError from service', () async { + // Arrange + when(() => mockService.transformPollingData(any())) + .thenReturn(const DashboardManagerState()); + when(() => mockService.checkRouterIsBack(any())).thenThrow( + const SerialNumberMismatchError(expected: 'EXPECTED', actual: 'ACTUAL'), + ); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act & Assert + await expectLater( + () => container + .read(dashboardManagerProvider.notifier) + .checkRouterIsBack(), + throwsA(isA()), + ); + }); + + test('propagates ConnectivityError from service', () async { + // Arrange + when(() => mockService.transformPollingData(any())) + .thenReturn(const DashboardManagerState()); + when(() => mockService.checkRouterIsBack(any())).thenThrow( + const ConnectivityError(message: 'Router unreachable'), + ); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act & Assert + await expectLater( + () => container + .read(dashboardManagerProvider.notifier) + .checkRouterIsBack(), + throwsA(isA()), + ); + }); + }); + + group('DashboardManagerNotifier - checkDeviceInfo', () { + test('delegates to service.checkDeviceInfo with cached state', () async { + // Arrange + final cachedDeviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'CACHED_SN') + .output, + ); + final initialState = DashboardManagerState(deviceInfo: cachedDeviceInfo); + + when(() => mockService.transformPollingData(any())) + .thenReturn(initialState); + when(() => mockService.checkDeviceInfo(cachedDeviceInfo)) + .thenAnswer((_) async => cachedDeviceInfo); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final result = await container + .read(dashboardManagerProvider.notifier) + .checkDeviceInfo(null); + + // Assert + verify(() => mockService.checkDeviceInfo(cachedDeviceInfo)).called(1); + expect(result.serialNumber, equals('CACHED_SN')); + }); + + test('delegates to service.checkDeviceInfo with null when no cache', + () async { + // Arrange + final freshDeviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'FRESH_SN') + .output, + ); + + when(() => mockService.transformPollingData(any())) + .thenReturn(const DashboardManagerState()); + when(() => mockService.checkDeviceInfo(null)) + .thenAnswer((_) async => freshDeviceInfo); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final result = await container + .read(dashboardManagerProvider.notifier) + .checkDeviceInfo(null); + + // Assert + verify(() => mockService.checkDeviceInfo(null)).called(1); + expect(result.serialNumber, equals('FRESH_SN')); + }); + + test('propagates ServiceError from service', () async { + // Arrange + when(() => mockService.transformPollingData(any())) + .thenReturn(const DashboardManagerState()); + when(() => mockService.checkDeviceInfo(any())).thenThrow( + const ResourceNotFoundError(), + ); + + container = ProviderContainer( + overrides: [ + dashboardManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act & Assert + expect( + () => container + .read(dashboardManagerProvider.notifier) + .checkDeviceInfo(null), + throwsA(isA()), + ); + }); + }); +} + +/// Mock polling notifier that returns the given data +class _MockPollingNotifier extends PollingNotifier { + final CoreTransactionData? _data; + + _MockPollingNotifier(this._data); + + @override + CoreTransactionData build() => + _data ?? + const CoreTransactionData(lastUpdate: 0, isReady: false, data: {}); +} diff --git a/test/core/jnap/providers/dashboard_manager_state_test.dart b/test/core/jnap/providers/dashboard_manager_state_test.dart new file mode 100644 index 000000000..14a1403f4 --- /dev/null +++ b/test/core/jnap/providers/dashboard_manager_state_test.dart @@ -0,0 +1,367 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; + +import '../../../mocks/test_data/dashboard_manager_test_data.dart'; + +void main() { + group('DashboardManagerState - default values', () { + test('has correct default values', () { + // Arrange & Act + const state = DashboardManagerState(); + + // Assert + expect(state.deviceInfo, isNull); + expect(state.mainRadios, isEmpty); + expect(state.guestRadios, isEmpty); + expect(state.isGuestNetworkEnabled, isFalse); + expect(state.uptimes, equals(0)); + expect(state.wanConnection, isNull); + expect(state.lanConnections, isEmpty); + expect(state.skuModelNumber, isNull); + expect(state.localTime, equals(0)); + expect(state.cpuLoad, isNull); + expect(state.memoryLoad, isNull); + }); + + test('stringify is enabled', () { + // Arrange + const state = DashboardManagerState(); + + // Assert + expect(state.stringify, isTrue); + }); + }); + + group('DashboardManagerState - equality and copyWith', () { + test('two states with same values are equal', () { + // Arrange + final deviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess().output, + ); + final state1 = DashboardManagerState( + deviceInfo: deviceInfo, + uptimes: 1000, + wanConnection: 'Linked-1000Mbps', + ); + final state2 = DashboardManagerState( + deviceInfo: deviceInfo, + uptimes: 1000, + wanConnection: 'Linked-1000Mbps', + ); + + // Assert + expect(state1, equals(state2)); + }); + + test('two states with different values are not equal', () { + // Arrange + const state1 = DashboardManagerState(uptimes: 1000); + const state2 = DashboardManagerState(uptimes: 2000); + + // Assert + expect(state1, isNot(equals(state2))); + }); + + test('states with different mainRadios are not equal', () { + // Arrange + final radios = DashboardManagerTestData.createRadioInfoSuccess(); + final radioList = (radios.output['radios'] as List) + .map((e) => RouterRadio.fromMap(e as Map)) + .toList(); + + final state1 = DashboardManagerState(mainRadios: radioList); + const state2 = DashboardManagerState(mainRadios: []); + + // Assert + expect(state1, isNot(equals(state2))); + }); + + test('states with different guestRadios are not equal', () { + // Arrange + final guestSettings = + DashboardManagerTestData.createGuestRadioSettingsSuccess(); + final guestRadioList = (guestSettings.output['radios'] as List) + .map((e) => GuestRadioInfo.fromMap(e as Map)) + .toList(); + + final state1 = DashboardManagerState(guestRadios: guestRadioList); + const state2 = DashboardManagerState(guestRadios: []); + + // Assert + expect(state1, isNot(equals(state2))); + }); + + test('copyWith preserves unmodified values', () { + // Arrange + final deviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess().output, + ); + final original = DashboardManagerState( + deviceInfo: deviceInfo, + uptimes: 1000, + wanConnection: 'Linked-1000Mbps', + localTime: 1234567890, + ); + + // Act + final copied = original.copyWith(uptimes: 2000); + + // Assert + expect(copied.deviceInfo, equals(deviceInfo)); + expect(copied.uptimes, equals(2000)); + expect(copied.wanConnection, equals('Linked-1000Mbps')); + expect(copied.localTime, equals(1234567890)); + }); + + test('copyWith can update all fields', () { + // Arrange + const original = DashboardManagerState(); + final newDeviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess().output, + ); + + // Act + final copied = original.copyWith( + deviceInfo: newDeviceInfo, + uptimes: 5000, + wanConnection: 'Linked-100Mbps', + lanConnections: ['Port1', 'Port2'], + skuModelNumber: 'MX5300', + localTime: 9999999999, + cpuLoad: '50%', + memoryLoad: '70%', + isGuestNetworkEnabled: true, + ); + + // Assert + expect(copied.deviceInfo, equals(newDeviceInfo)); + expect(copied.uptimes, equals(5000)); + expect(copied.wanConnection, equals('Linked-100Mbps')); + expect(copied.lanConnections, equals(['Port1', 'Port2'])); + expect(copied.skuModelNumber, equals('MX5300')); + expect(copied.localTime, equals(9999999999)); + expect(copied.cpuLoad, equals('50%')); + expect(copied.memoryLoad, equals('70%')); + expect(copied.isGuestNetworkEnabled, isTrue); + }); + + test('copyWith can update mainRadios and guestRadios', () { + // Arrange + const original = DashboardManagerState(); + final radios = DashboardManagerTestData.createRadioInfoSuccess(); + final radioList = (radios.output['radios'] as List) + .map((e) => RouterRadio.fromMap(e as Map)) + .toList(); + final guestSettings = + DashboardManagerTestData.createGuestRadioSettingsSuccess(); + final guestRadioList = (guestSettings.output['radios'] as List) + .map((e) => GuestRadioInfo.fromMap(e as Map)) + .toList(); + + // Act + final copied = original.copyWith( + mainRadios: radioList, + guestRadios: guestRadioList, + ); + + // Assert + expect(copied.mainRadios.length, equals(2)); + expect(copied.guestRadios.length, equals(2)); + expect(copied.mainRadios[0].band, equals('2.4GHz')); + expect(copied.mainRadios[1].band, equals('5GHz')); + }); + }); + + group('DashboardManagerState - serialization', () { + test('toMap produces correct map structure', () { + // Arrange + final deviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess().output, + ); + final state = DashboardManagerState( + deviceInfo: deviceInfo, + uptimes: 1000, + skuModelNumber: 'MX5300', + localTime: 1234567890, + isGuestNetworkEnabled: false, + ); + + // Act + final map = state.toMap(); + + // Assert + expect(map['deviceInfo'], isNotNull); + expect(map['uptimes'], equals(1000)); + expect(map['skuModelNumber'], equals('MX5300')); + expect(map['localTime'], equals(1234567890)); + expect(map['isGuestNetworkEnabled'], equals(false)); + }); + + test('toMap removes null values', () { + // Arrange + const state = DashboardManagerState(uptimes: 100); + + // Act + final map = state.toMap(); + + // Assert + expect(map.containsKey('deviceInfo'), isFalse); + expect(map.containsKey('wanConnection'), isFalse); + expect(map.containsKey('skuModelNumber'), isFalse); + expect(map.containsKey('cpuLoad'), isFalse); + expect(map.containsKey('memoryLoad'), isFalse); + expect(map['uptimes'], equals(100)); + }); + + test('toMap includes mainRadios and guestRadios', () { + // Arrange + final radios = DashboardManagerTestData.createRadioInfoSuccess(); + final radioList = (radios.output['radios'] as List) + .map((e) => RouterRadio.fromMap(e as Map)) + .toList(); + final guestSettings = + DashboardManagerTestData.createGuestRadioSettingsSuccess(); + final guestRadioList = (guestSettings.output['radios'] as List) + .map((e) => GuestRadioInfo.fromMap(e as Map)) + .toList(); + + final state = DashboardManagerState( + mainRadios: radioList, + guestRadios: guestRadioList, + ); + + // Act + final map = state.toMap(); + + // Assert + expect(map['mainRadios'], isA()); + expect((map['mainRadios'] as List).length, equals(2)); + expect(map['guestRadios'], isA()); + expect((map['guestRadios'] as List).length, equals(2)); + }); + + test('fromMap creates state from map', () { + // Arrange + final deviceInfoOutput = + DashboardManagerTestData.createDeviceInfoSuccess().output; + final map = { + 'deviceInfo': deviceInfoOutput, + 'mainRadios': >[], + 'guestRadios': >[], + 'isGuestNetworkEnabled': true, + 'uptimes': 5000, + 'skuModelNumber': 'MX5300', + 'localTime': 9876543210, + 'cpuLoad': '25%', + 'memoryLoad': '50%', + }; + + // Act + final state = DashboardManagerState.fromMap(map); + + // Assert + expect(state.deviceInfo?.serialNumber, equals('TEST123456')); + expect(state.isGuestNetworkEnabled, isTrue); + expect(state.uptimes, equals(5000)); + expect(state.skuModelNumber, equals('MX5300')); + expect(state.localTime, equals(9876543210)); + expect(state.cpuLoad, equals('25%')); + expect(state.memoryLoad, equals('50%')); + }); + + test('fromMap handles null deviceInfo', () { + // Arrange + final map = { + 'deviceInfo': null, + 'mainRadios': >[], + 'guestRadios': >[], + 'isGuestNetworkEnabled': false, + 'uptimes': 0, + 'localTime': 0, + }; + + // Act + final state = DashboardManagerState.fromMap(map); + + // Assert + expect(state.deviceInfo, isNull); + }); + + test('fromMap handles null radios lists', () { + // Arrange + final map = { + 'mainRadios': null, + 'guestRadios': null, + 'isGuestNetworkEnabled': false, + 'uptimes': 0, + 'localTime': 0, + }; + + // Act + final state = DashboardManagerState.fromMap(map); + + // Assert + expect(state.mainRadios, isEmpty); + expect(state.guestRadios, isEmpty); + }); + + test('toJson and fromJson are reversible', () { + // Arrange + final deviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess().output, + ); + final original = DashboardManagerState( + deviceInfo: deviceInfo, + uptimes: 1000, + isGuestNetworkEnabled: true, + localTime: 1234567890, + ); + + // Act + final json = original.toJson(); + final restored = DashboardManagerState.fromJson(json); + + // Assert + expect(restored.deviceInfo?.serialNumber, + equals(original.deviceInfo?.serialNumber)); + expect(restored.uptimes, equals(original.uptimes)); + expect(restored.isGuestNetworkEnabled, + equals(original.isGuestNetworkEnabled)); + expect(restored.localTime, equals(original.localTime)); + }); + + test('toJson and fromJson preserve mainRadios and guestRadios', () { + // Arrange + final radios = DashboardManagerTestData.createRadioInfoSuccess(); + final radioList = (radios.output['radios'] as List) + .map((e) => RouterRadio.fromMap(e as Map)) + .toList(); + final guestSettings = + DashboardManagerTestData.createGuestRadioSettingsSuccess(); + final guestRadioList = (guestSettings.output['radios'] as List) + .map((e) => GuestRadioInfo.fromMap(e as Map)) + .toList(); + + final original = DashboardManagerState( + mainRadios: radioList, + guestRadios: guestRadioList, + isGuestNetworkEnabled: true, + uptimes: 0, + localTime: 0, + ); + + // Act + final json = original.toJson(); + final restored = DashboardManagerState.fromJson(json); + + // Assert + expect(restored.mainRadios.length, equals(original.mainRadios.length)); + expect(restored.guestRadios.length, equals(original.guestRadios.length)); + expect(restored.mainRadios[0].band, equals('2.4GHz')); + expect(restored.mainRadios[1].band, equals('5GHz')); + }); + }); +} diff --git a/test/core/jnap/providers/device_manager_provider_test.dart b/test/core/jnap/providers/device_manager_provider_test.dart new file mode 100644 index 000000000..28cc9f426 --- /dev/null +++ b/test/core/jnap/providers/device_manager_provider_test.dart @@ -0,0 +1,286 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/jnap/models/device.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/services/device_manager_service.dart'; + +import '../../../mocks/test_data/device_manager_test_data.dart'; + +class MockDeviceManagerService extends Mock implements DeviceManagerService {} + +class MockPollingNotifier extends Mock implements PollingNotifier {} + +void main() { + late MockDeviceManagerService mockService; + late ProviderContainer container; + + setUp(() { + mockService = MockDeviceManagerService(); + }); + + tearDown(() { + container.dispose(); + }); + + group('DeviceManagerNotifier - build', () { + test('delegates to service.transformPollingData', () { + // Arrange + final pollingData = DeviceManagerTestData.createCompletePollingData(); + final expectedState = DeviceManagerState( + deviceList: [ + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()), + ], + lastUpdateTime: pollingData.lastUpdate, + ); + + when(() => mockService.transformPollingData(pollingData)) + .thenReturn(expectedState); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(pollingData)), + ], + ); + + // Act + final state = container.read(deviceManagerProvider); + + // Assert + verify(() => mockService.transformPollingData(pollingData)).called(1); + expect(state, equals(expectedState)); + }); + + test('handles null polling data', () { + // Arrange + const expectedState = DeviceManagerState(); + + // When polling data is not ready (null), service should return empty state + when(() => mockService.transformPollingData(any())) + .thenReturn(expectedState); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final state = container.read(deviceManagerProvider); + + // Assert + // The mock notifier returns a default CoreTransactionData when null is passed + verify(() => mockService.transformPollingData(any())).called(1); + expect(state.deviceList, isEmpty); + }); + }); + + group('DeviceManagerNotifier - updateDeviceNameAndIcon', () { + test('delegates to service and updates state', () async { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final initialState = DeviceManagerState( + deviceList: [masterDevice], + ); + final updatedProperties = [ + RawDeviceProperty(name: 'userDeviceName', value: 'New Name'), + ]; + + when(() => mockService.transformPollingData(any())) + .thenReturn(initialState); + when(() => mockService.updateDeviceNameAndIcon( + targetId: masterDevice.deviceID, + newName: 'New Name', + isLocation: false, + icon: null, + )).thenAnswer((_) async => updatedProperties); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + await container + .read(deviceManagerProvider.notifier) + .updateDeviceNameAndIcon( + targetId: masterDevice.deviceID, + newName: 'New Name', + isLocation: false, + ); + + // Assert + verify(() => mockService.updateDeviceNameAndIcon( + targetId: masterDevice.deviceID, + newName: 'New Name', + isLocation: false, + icon: null, + )).called(1); + }); + }); + + group('DeviceManagerNotifier - deleteDevices', () { + test('delegates to service with empty list returns immediately', () async { + // Arrange + when(() => mockService.transformPollingData(any())) + .thenReturn(const DeviceManagerState()); + when(() => mockService.deleteDevices([])).thenAnswer((_) async => {}); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifierWithForce()), + ], + ); + + // Act + await container + .read(deviceManagerProvider.notifier) + .deleteDevices(deviceIds: []); + + // Assert + verify(() => mockService.deleteDevices([])).called(1); + }); + + test('delegates to service and removes deleted devices from state', + () async { + // Arrange + final device1 = + LinksysDevice.fromMap(DeviceManagerTestData.createExternalDevice( + deviceId: 'device-1', + )); + final device2 = + LinksysDevice.fromMap(DeviceManagerTestData.createExternalDevice( + deviceId: 'device-2', + )); + final initialState = DeviceManagerState( + deviceList: [device1, device2], + ); + + when(() => mockService.transformPollingData(any())) + .thenReturn(initialState); + when(() => mockService.deleteDevices(['device-1'])) + .thenAnswer((_) async => {'device-1': true}); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifierWithForce()), + ], + ); + + // Act + await container.read(deviceManagerProvider.notifier).deleteDevices( + deviceIds: ['device-1'], + ); + + // Assert + verify(() => mockService.deleteDevices(['device-1'])).called(1); + final state = container.read(deviceManagerProvider); + expect(state.deviceList.length, equals(1)); + expect(state.deviceList.first.deviceID, equals('device-2')); + }); + }); + + group('DeviceManagerNotifier - deauthClient', () { + test('delegates to service', () async { + // Arrange + when(() => mockService.transformPollingData(any())) + .thenReturn(const DeviceManagerState()); + when(() => mockService.deauthClient('AA:BB:CC:DD:EE:FF')) + .thenAnswer((_) async {}); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifierWithForce()), + ], + ); + + // Act + await container + .read(deviceManagerProvider.notifier) + .deauthClient(macAddress: 'AA:BB:CC:DD:EE:FF'); + + // Assert + verify(() => mockService.deauthClient('AA:BB:CC:DD:EE:FF')).called(1); + }); + }); + + group('DeviceManagerNotifier - state query methods', () { + test('isEmptyState returns true when deviceList is empty', () { + // Arrange + when(() => mockService.transformPollingData(any())) + .thenReturn(const DeviceManagerState()); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final isEmpty = + container.read(deviceManagerProvider.notifier).isEmptyState(); + + // Assert + expect(isEmpty, isTrue); + }); + + test('isEmptyState returns false when deviceList has items', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final initialState = DeviceManagerState(deviceList: [masterDevice]); + + when(() => mockService.transformPollingData(any())) + .thenReturn(initialState); + + container = ProviderContainer( + overrides: [ + deviceManagerServiceProvider.overrideWithValue(mockService), + pollingProvider.overrideWith(() => _MockPollingNotifier(null)), + ], + ); + + // Act + final isEmpty = + container.read(deviceManagerProvider.notifier).isEmptyState(); + + // Assert + expect(isEmpty, isFalse); + }); + }); +} + +/// Mock polling notifier that returns the given data +class _MockPollingNotifier extends PollingNotifier { + final CoreTransactionData? _data; + + _MockPollingNotifier(this._data); + + @override + CoreTransactionData build() => + _data ?? + const CoreTransactionData(lastUpdate: 0, isReady: false, data: {}); +} + +/// Mock polling notifier with forcePolling support +class _MockPollingNotifierWithForce extends PollingNotifier { + @override + CoreTransactionData build() => + const CoreTransactionData(lastUpdate: 0, isReady: false, data: {}); + + @override + Future forcePolling() async { + // No-op for testing + } +} diff --git a/test/core/jnap/providers/device_manager_state_test.dart b/test/core/jnap/providers/device_manager_state_test.dart new file mode 100644 index 000000000..245faf7cd --- /dev/null +++ b/test/core/jnap/providers/device_manager_state_test.dart @@ -0,0 +1,549 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; + +import '../../../mocks/test_data/device_manager_test_data.dart'; + +void main() { + group('LinksysDevice', () { + group('fromMap', () { + test('creates device from valid map', () { + // Arrange + final map = DeviceManagerTestData.createMasterDevice(); + + // Act + final device = LinksysDevice.fromMap(map); + + // Assert + expect(device.deviceID, equals('master-device-id-001')); + expect(device.friendlyName, equals('Master Router')); + expect(device.isAuthority, isTrue); + expect(device.nodeType, equals('Master')); + expect(device.connections, hasLength(1)); + expect(device.properties, hasLength(1)); + }); + + test('creates external device from valid map', () { + // Arrange + final map = DeviceManagerTestData.createExternalDevice(); + + // Act + final device = LinksysDevice.fromMap(map); + + // Assert + expect(device.deviceID, equals('external-device-id-001')); + expect(device.friendlyName, equals('iPhone')); + expect(device.isAuthority, isFalse); + expect(device.nodeType, isNull); + }); + + test('handles optional fields correctly', () { + // Arrange + final map = DeviceManagerTestData.createMasterDevice(); + + // Act + final device = LinksysDevice.fromMap(map); + + // Assert - defaults for LinksysDevice-specific fields + expect(device.connectedDevices, isEmpty); + expect(device.connectedWifiType, equals(WifiConnectionType.main)); + expect(device.signalDecibels, isNull); + expect(device.upstream, isNull); + expect(device.connectionType, equals('wired')); + expect(device.speedMbps, equals('--')); + expect(device.mloList, isEmpty); + }); + }); + + group('toMap', () { + test('converts device to map', () { + // Arrange + final originalMap = DeviceManagerTestData.createMasterDevice(); + final device = LinksysDevice.fromMap(originalMap); + + // Act + final result = device.toMap(); + + // Assert + expect(result['deviceID'], equals('master-device-id-001')); + expect(result['friendlyName'], equals('Master Router')); + expect(result['isAuthority'], isTrue); + expect(result['nodeType'], equals('Master')); + expect(result['connectedDevices'], isEmpty); + expect(result['connectedWifiType'], equals('main')); + }); + + test('excludes null values from map', () { + // Arrange + final originalMap = DeviceManagerTestData.createMasterDevice(); + final device = LinksysDevice.fromMap(originalMap); + + // Act + final result = device.toMap(); + + // Assert - null values should be removed + expect(result.containsKey('signalDecibels'), isFalse); + expect(result.containsKey('upstream'), isFalse); + expect(result.containsKey('wirelessConnectionInfo'), isFalse); + }); + }); + + group('toJson and fromJson', () { + test('toJson produces valid JSON string', () { + // Arrange + final originalMap = DeviceManagerTestData.createMasterDevice(); + final device = LinksysDevice.fromMap(originalMap); + + // Act + final jsonStr = device.toJson(); + + // Assert + expect(() => json.decode(jsonStr), returnsNormally); + }); + + // Note: Full roundtrip test skipped due to known issue in LinksysDevice.fromMap + // where mloList is assigned as List instead of List. + // This is a pre-existing bug in the source code, not introduced by this feature. + }); + + group('copyWith', () { + test('copies device with new values', () { + // Arrange + final originalMap = DeviceManagerTestData.createMasterDevice(); + final device = LinksysDevice.fromMap(originalMap); + + // Act + final copied = device.copyWith( + friendlyName: 'New Name', + signalDecibels: -50, + speedMbps: '1000', + ); + + // Assert + expect(copied.friendlyName, equals('New Name')); + expect(copied.signalDecibels, equals(-50)); + expect(copied.speedMbps, equals('1000')); + // Original values preserved + expect(copied.deviceID, equals(device.deviceID)); + expect(copied.isAuthority, equals(device.isAuthority)); + }); + + test('preserves original values when not specified', () { + // Arrange + final originalMap = DeviceManagerTestData.createMasterDevice(); + final device = LinksysDevice.fromMap(originalMap); + + // Act + final copied = device.copyWith(); + + // Assert - all values should be the same + expect(copied.deviceID, equals(device.deviceID)); + expect(copied.friendlyName, equals(device.friendlyName)); + expect(copied.isAuthority, equals(device.isAuthority)); + expect(copied.nodeType, equals(device.nodeType)); + expect(copied.connections.length, equals(device.connections.length)); + }); + }); + + group('isMaster', () { + test('returns true when isAuthority is true', () { + // Arrange + final map = DeviceManagerTestData.createMasterDevice(); + final device = LinksysDevice.fromMap(map); + + // Act & Assert + expect(device.isMaster, isTrue); + }); + + test('returns true when nodeType is Master', () { + // Arrange + final map = DeviceManagerTestData.createMasterDevice(); + map['isAuthority'] = false; + map['nodeType'] = 'Master'; + final device = LinksysDevice.fromMap(map); + + // Act & Assert + expect(device.isMaster, isTrue); + }); + + test('returns false for slave devices', () { + // Arrange + final map = DeviceManagerTestData.createSlaveDevice(); + final device = LinksysDevice.fromMap(map); + + // Act & Assert + expect(device.isMaster, isFalse); + }); + + test('returns false for external devices', () { + // Arrange + final map = DeviceManagerTestData.createExternalDevice(); + final device = LinksysDevice.fromMap(map); + + // Act & Assert + expect(device.isMaster, isFalse); + }); + }); + }); + + group('DeviceManagerState', () { + group('constructor', () { + test('creates empty state with defaults', () { + // Act + const state = DeviceManagerState(); + + // Assert + expect(state.wirelessConnections, isEmpty); + expect(state.radioInfos, isEmpty); + expect(state.guestRadioSettings, isNull); + expect(state.deviceList, isEmpty); + expect(state.wanStatus, isNull); + expect(state.backhaulInfoData, isEmpty); + expect(state.lastUpdateTime, equals(0)); + }); + + test('creates state with provided values', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + + // Act + final state = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + + // Assert + expect(state.deviceList, hasLength(1)); + expect(state.lastUpdateTime, equals(12345)); + }); + }); + + group('copyWith', () { + test('copies state with new values', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final state = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + + // Act + final copied = state.copyWith( + lastUpdateTime: 99999, + ); + + // Assert + expect(copied.lastUpdateTime, equals(99999)); + expect(copied.deviceList, hasLength(1)); + }); + + test('preserves original values when not specified', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final state = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + + // Act + final copied = state.copyWith(); + + // Assert + expect(copied.deviceList, hasLength(1)); + expect(copied.lastUpdateTime, equals(12345)); + }); + }); + + group('computed properties', () { + late LinksysDevice masterDevice; + late LinksysDevice slaveDevice; + late LinksysDevice mainWifiDevice; + late LinksysDevice guestWifiDevice; + + setUp(() { + masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + slaveDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createSlaveDevice()); + mainWifiDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createExternalDevice( + deviceId: 'main-wifi-device', + )); + guestWifiDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createExternalDevice( + deviceId: 'guest-wifi-device', + isGuest: true, + )).copyWith(connectedWifiType: WifiConnectionType.guest); + }); + + test('nodeDevices returns only node devices', () { + // Arrange + final state = DeviceManagerState( + deviceList: [masterDevice, slaveDevice, mainWifiDevice], + ); + + // Act + final nodes = state.nodeDevices; + + // Assert + expect(nodes, hasLength(2)); + expect(nodes.any((d) => d.deviceID == 'master-device-id-001'), isTrue); + expect(nodes.any((d) => d.deviceID == 'slave-device-id-001'), isTrue); + }); + + test( + 'nodeDevices includes devices with isAuthority true even without nodeType', + () { + // Arrange - device with isAuthority true but nodeType null (factory settings) + final factoryDevice = masterDevice.copyWith(nodeType: null); + final state = DeviceManagerState( + deviceList: [factoryDevice], + ); + + // Act + final nodes = state.nodeDevices; + + // Assert + expect(nodes, hasLength(1)); + }); + + test('externalDevices returns only external devices', () { + // Arrange + final state = DeviceManagerState( + deviceList: [ + masterDevice, + slaveDevice, + mainWifiDevice, + guestWifiDevice + ], + ); + + // Act + final external = state.externalDevices; + + // Assert + expect(external, hasLength(2)); + expect(external.every((d) => d.nodeType == null), isTrue); + }); + + test('mainWifiDevices returns only main wifi connected devices', () { + // Arrange + final state = DeviceManagerState( + deviceList: [masterDevice, mainWifiDevice, guestWifiDevice], + ); + + // Act + final mainWifi = state.mainWifiDevices; + + // Assert + expect(mainWifi, hasLength(2)); // master + mainWifiDevice + expect( + mainWifi + .every((d) => d.connectedWifiType == WifiConnectionType.main), + isTrue); + }); + + test('guestWifiDevices returns only guest wifi connected devices', () { + // Arrange + final state = DeviceManagerState( + deviceList: [masterDevice, mainWifiDevice, guestWifiDevice], + ); + + // Act + final guestWifi = state.guestWifiDevices; + + // Assert + expect(guestWifi, hasLength(1)); + expect(guestWifi.first.deviceID, equals('guest-wifi-device')); + }); + + test('masterDevice returns the master device', () { + // Arrange + final state = DeviceManagerState( + deviceList: [masterDevice, slaveDevice, mainWifiDevice], + ); + + // Act + final master = state.masterDevice; + + // Assert + expect(master.deviceID, equals('master-device-id-001')); + expect(master.isMaster, isTrue); + }); + + test('slaveDevices returns only slave devices', () { + // Arrange + final state = DeviceManagerState( + deviceList: [masterDevice, slaveDevice, mainWifiDevice], + ); + + // Act + final slaves = state.slaveDevices; + + // Assert + expect(slaves, hasLength(1)); + expect(slaves.first.deviceID, equals('slave-device-id-001')); + expect(slaves.first.nodeType, equals('Slave')); + }); + }); + + group('Equatable', () { + test('two states with same values are equal', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final state1 = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + final state2 = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + + // Act & Assert + expect(state1, equals(state2)); + }); + + test('two states with different values are not equal', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final state1 = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + final state2 = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 99999, + ); + + // Act & Assert + expect(state1, isNot(equals(state2))); + }); + + test('props includes all relevant fields', () { + // Arrange + const state = DeviceManagerState(); + + // Act + final props = state.props; + + // Assert + expect(props, hasLength(6)); + }); + }); + + group('toMap and fromMap', () { + test('roundtrip serialization works for empty state', () { + // Arrange + const state = DeviceManagerState( + wirelessConnections: {}, + radioInfos: {}, + deviceList: [], + backhaulInfoData: [], + lastUpdateTime: 12345, + ); + + // Act + final map = state.toMap(); + final restored = DeviceManagerState.fromMap(map); + + // Assert + expect(restored.wirelessConnections, isEmpty); + expect(restored.radioInfos, isEmpty); + expect(restored.deviceList, isEmpty); + expect(restored.backhaulInfoData, isEmpty); + expect(restored.lastUpdateTime, equals(12345)); + }); + + test('roundtrip serialization works with devices', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final state = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + + // Act + final map = state.toMap(); + final restored = DeviceManagerState.fromMap(map); + + // Assert + expect(restored.deviceList, hasLength(1)); + expect( + restored.deviceList.first.deviceID, equals('master-device-id-001')); + expect(restored.lastUpdateTime, equals(12345)); + }); + + test('toMap excludes null values', () { + // Arrange + const state = DeviceManagerState(); + + // Act + final map = state.toMap(); + + // Assert + expect(map.containsKey('guestRadioSettings'), isFalse); + expect(map.containsKey('wanStatus'), isFalse); + }); + }); + + group('toJson and fromJson', () { + test('toJson produces valid JSON string', () { + // Arrange + const state = DeviceManagerState(lastUpdateTime: 12345); + + // Act + final jsonStr = state.toJson(); + + // Assert + expect(() => json.decode(jsonStr), returnsNormally); + }); + + test('toJson produces valid JSON string with devices', () { + // Arrange + final masterDevice = + LinksysDevice.fromMap(DeviceManagerTestData.createMasterDevice()); + final state = DeviceManagerState( + deviceList: [masterDevice], + lastUpdateTime: 12345, + ); + + // Act + final jsonStr = state.toJson(); + + // Assert + expect(() => json.decode(jsonStr), returnsNormally); + final decoded = json.decode(jsonStr) as Map; + expect(decoded['lastUpdateTime'], equals(12345)); + expect((decoded['deviceList'] as List).length, equals(1)); + }); + + // Note: Full roundtrip test (fromJson) skipped due to known issue in LinksysDevice.fromMap + // where mloList is assigned as List instead of List. + // This is a pre-existing bug in the source code, not introduced by this feature. + }); + }); + + group('WifiConnectionType', () { + test('main has correct value', () { + expect(WifiConnectionType.main.value, equals('main')); + }); + + test('guest has correct value', () { + expect(WifiConnectionType.guest.value, equals('guest')); + }); + + test('values contains both types', () { + expect(WifiConnectionType.values, hasLength(2)); + expect(WifiConnectionType.values, contains(WifiConnectionType.main)); + expect(WifiConnectionType.values, contains(WifiConnectionType.guest)); + }); + }); +} diff --git a/test/core/jnap/services/dashboard_manager_service_test.dart b/test/core/jnap/services/dashboard_manager_service_test.dart new file mode 100644 index 000000000..cd65190e5 --- /dev/null +++ b/test/core/jnap/services/dashboard_manager_service_test.dart @@ -0,0 +1,364 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/jnap/services/dashboard_manager_service.dart'; + +import '../../../mocks/test_data/dashboard_manager_test_data.dart'; + +class MockRouterRepository extends Mock implements RouterRepository {} + +void main() { + late DashboardManagerService service; + late MockRouterRepository mockRouterRepository; + + setUpAll(() { + registerFallbackValue(JNAPAction.getDeviceInfo); + }); + + setUp(() { + mockRouterRepository = MockRouterRepository(); + service = DashboardManagerService(mockRouterRepository); + }); + + group('DashboardManagerService - transformPollingData', () { + // T018: transformPollingData returns default state when pollingResult is null + test('returns default state when pollingResult is null', () { + // Arrange + const CoreTransactionData? pollingData = null; + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + expect(result.deviceInfo, isNull); + expect(result.mainRadios, isEmpty); + expect(result.guestRadios, isEmpty); + expect(result.isGuestNetworkEnabled, isFalse); + expect(result.uptimes, equals(0)); + expect(result.wanConnection, isNull); + expect(result.lanConnections, isEmpty); + expect(result.skuModelNumber, isNull); + expect(result.cpuLoad, isNull); + expect(result.memoryLoad, isNull); + }); + + // T019: transformPollingData returns complete state when all actions succeed + test('returns complete state when all actions succeed', () { + // Arrange + final pollingData = + DashboardManagerTestData.createSuccessfulPollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + expect(result.deviceInfo, isNotNull); + expect(result.deviceInfo?.serialNumber, equals('TEST123456')); + expect(result.deviceInfo?.modelNumber, equals('MX5300')); + expect(result.mainRadios, isNotEmpty); + expect(result.mainRadios.length, equals(2)); // 2.4GHz and 5GHz + expect(result.guestRadios, isNotEmpty); + expect(result.guestRadios.length, equals(2)); + expect(result.isGuestNetworkEnabled, isFalse); + expect(result.uptimes, equals(86400)); + expect(result.wanConnection, equals('Linked-1000Mbps')); + expect(result.lanConnections, isNotEmpty); + expect(result.skuModelNumber, equals('MX5300-SKU')); + expect(result.localTime, isNotNull); + expect(result.localTime, isNot(equals(0))); + }); + + // T020: transformPollingData returns partial state when some actions fail + test('returns partial state when some actions fail', () { + // Arrange + final pollingData = + DashboardManagerTestData.createPartialErrorPollingData( + failedActions: { + JNAPAction.getRadioInfo, + JNAPAction.getGuestRadioSettings + }, + ); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + // Device info should still be present + expect(result.deviceInfo, isNotNull); + // Radio info should be empty due to failure + expect(result.mainRadios, isEmpty); + expect(result.guestRadios, isEmpty); + // System stats should still be present + expect(result.uptimes, equals(86400)); + // Ethernet ports should still be present + expect(result.wanConnection, isNotNull); + }); + + // T021: transformPollingData correctly parses each JNAP action response + test('correctly parses each JNAP action response', () { + // Arrange + final pollingData = DashboardManagerTestData.createSuccessfulPollingData( + deviceInfo: DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'CUSTOM_SN', + modelNumber: 'CUSTOM_MODEL', + firmwareVersion: '2.0.0', + ), + systemStats: DashboardManagerTestData.createSystemStatsSuccess( + uptimeSeconds: 172800, + cpuLoad: '45%', + memoryLoad: '60%', + ), + ethernetPortConnections: + DashboardManagerTestData.createEthernetPortConnectionsSuccess( + lanPortConnections: ['Linked-100Mbps', 'None'], + wanPortConnection: 'Linked-100Mbps', + ), + ); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result.deviceInfo?.serialNumber, equals('CUSTOM_SN')); + expect(result.deviceInfo?.modelNumber, equals('CUSTOM_MODEL')); + expect(result.deviceInfo?.firmwareVersion, equals('2.0.0')); + expect(result.uptimes, equals(172800)); + expect(result.cpuLoad, equals('45%')); + expect(result.memoryLoad, equals('60%')); + expect(result.wanConnection, equals('Linked-100Mbps')); + expect(result.lanConnections, equals(['Linked-100Mbps', 'None'])); + }); + + // T022: transformPollingData uses default localTime when parsing fails + test('uses current time when localTime parsing fails', () { + // Arrange + final pollingData = + DashboardManagerTestData.createPollingDataWithInvalidTime(); + final beforeTest = DateTime.now().millisecondsSinceEpoch; + + // Act + final result = service.transformPollingData(pollingData); + final afterTest = DateTime.now().millisecondsSinceEpoch; + + // Assert + expect(result.localTime, isNotNull); + expect(result.localTime, greaterThanOrEqualTo(beforeTest)); + expect(result.localTime, lessThanOrEqualTo(afterTest)); + }); + + test('correctly parses radio info with multiple bands', () { + // Arrange + final pollingData = + DashboardManagerTestData.createSuccessfulPollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result.mainRadios.length, equals(2)); + expect(result.mainRadios.any((r) => r.band == '2.4GHz'), isTrue); + expect(result.mainRadios.any((r) => r.band == '5GHz'), isTrue); + }); + + test('correctly parses guest radio settings', () { + // Arrange + final pollingData = DashboardManagerTestData.createSuccessfulPollingData( + guestRadioSettings: + DashboardManagerTestData.createGuestRadioSettingsSuccess( + isGuestNetworkEnabled: true, + ), + ); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result.isGuestNetworkEnabled, isTrue); + expect(result.guestRadios.length, equals(2)); + }); + }); + + group('DashboardManagerService - checkRouterIsBack', () { + // T034: checkRouterIsBack returns NodeDeviceInfo when SN matches + test('returns NodeDeviceInfo when serial number matches', () async { + // Arrange + const expectedSN = 'TEST123456'; + final deviceInfoOutput = DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: expectedSN) + .output; + + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'OK', output: deviceInfoOutput)); + + // Act + final result = await service.checkRouterIsBack(expectedSN); + + // Assert + expect(result, isA()); + expect(result.serialNumber, equals(expectedSN)); + }); + + // T035: checkRouterIsBack throws SerialNumberMismatchError when SN doesn't match + test('throws SerialNumberMismatchError when serial number does not match', + () async { + // Arrange + const expectedSN = 'EXPECTED_SN'; + const actualSN = 'ACTUAL_SN'; + final deviceInfoOutput = DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: actualSN) + .output; + + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'OK', output: deviceInfoOutput)); + + // Act & Assert + expect( + () => service.checkRouterIsBack(expectedSN), + throwsA(isA()), + ); + }); + + // T036: checkRouterIsBack throws ConnectivityError when router unreachable + test('throws ConnectivityError when router is unreachable', () async { + // Arrange + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + )).thenThrow(Exception('Network error')); + + // Act & Assert + expect( + () => service.checkRouterIsBack('TEST123456'), + throwsA(isA()), + ); + }); + + // T037: checkRouterIsBack maps JNAPError to ServiceError correctly + test('maps JNAPError to ServiceError correctly', () async { + // Arrange + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + )) + .thenThrow(const JNAPError( + result: '_ErrorUnauthorized', error: 'Unauthorized')); + + // Act & Assert + expect( + () => service.checkRouterIsBack('TEST123456'), + throwsA(isA()), + ); + }); + + test('returns NodeDeviceInfo when expected serial number is empty', + () async { + // Arrange - empty SN should skip validation + final deviceInfoOutput = DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'ANY_SN') + .output; + + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'OK', output: deviceInfoOutput)); + + // Act + final result = await service.checkRouterIsBack(''); + + // Assert + expect(result, isA()); + expect(result.serialNumber, equals('ANY_SN')); + }); + }); + + group('DashboardManagerService - checkDeviceInfo', () { + // T044: checkDeviceInfo returns cached value immediately when available + test('returns cached value immediately when available', () async { + // Arrange + final cachedDeviceInfo = NodeDeviceInfo.fromJson( + DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'CACHED_SN') + .output, + ); + + // Act + final result = await service.checkDeviceInfo(cachedDeviceInfo); + + // Assert + expect(result, equals(cachedDeviceInfo)); + expect(result.serialNumber, equals('CACHED_SN')); + // No API call should be made when cached value exists + verifyZeroInteractions(mockRouterRepository); + }); + + // T045: checkDeviceInfo makes API call when cached value is null + test('makes API call when cached value is null', () async { + // Arrange + final deviceInfoOutput = DashboardManagerTestData.createDeviceInfoSuccess( + serialNumber: 'FRESH_SN') + .output; + + when(() => mockRouterRepository.send( + any(), + retries: any(named: 'retries'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'OK', output: deviceInfoOutput)); + + // Act + final result = await service.checkDeviceInfo(null); + + // Assert + expect(result, isA()); + expect(result.serialNumber, equals('FRESH_SN')); + verify(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + retries: 0, + timeoutMs: 3000, + )).called(1); + }); + + // T046: checkDeviceInfo throws ServiceError on API failure + test('throws ServiceError on API failure', () async { + // Arrange + when(() => mockRouterRepository.send( + any(), + retries: any(named: 'retries'), + timeoutMs: any(named: 'timeoutMs'), + )) + .thenThrow(const JNAPError( + result: 'ErrorDeviceNotFound', error: 'Device not found')); + + // Act & Assert + expect( + () => service.checkDeviceInfo(null), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/jnap/services/device_manager_service_test.dart b/test/core/jnap/services/device_manager_service_test.dart new file mode 100644 index 000000000..2a8efb233 --- /dev/null +++ b/test/core/jnap/services/device_manager_service_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/jnap/services/device_manager_service.dart'; + +import '../../../mocks/test_data/device_manager_test_data.dart'; + +class MockRouterRepository extends Mock implements RouterRepository {} + +void main() { + late DeviceManagerService service; + late MockRouterRepository mockRouterRepository; + + setUp(() { + mockRouterRepository = MockRouterRepository(); + service = DeviceManagerService(mockRouterRepository); + }); + + group('DeviceManagerService - transformPollingData', () { + test('returns empty default state when polling data is null', () { + // Arrange + final pollingData = DeviceManagerTestData.createNullPollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + expect(result.deviceList, isEmpty); + expect(result.wirelessConnections, isEmpty); + expect(result.radioInfos, isEmpty); + expect(result.backhaulInfoData, isEmpty); + expect(result.wanStatus, isNull); + expect(result.guestRadioSettings, isNull); + }); + + test( + 'returns complete state with all device data when valid polling data provided', + () { + // Arrange + final pollingData = DeviceManagerTestData.createCompletePollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + expect(result.deviceList, isNotEmpty); + expect(result.deviceList.length, + greaterThanOrEqualTo(3)); // master, slave, 2 external + expect(result.wirelessConnections, isNotEmpty); + expect(result.radioInfos, isNotEmpty); + expect(result.wanStatus, isNotNull); + expect(result.lastUpdateTime, equals(1234567890)); + }); + + test('returns partial state when some JNAP actions failed', () { + // Arrange + final pollingData = DeviceManagerTestData.createPartialErrorPollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + // Should still have devices even though backhaul info failed + expect(result.deviceList, isNotEmpty); + expect(result.wanStatus, isNotNull); + }); + + test('correctly categorizes node devices and external devices', () { + // Arrange + final pollingData = DeviceManagerTestData.createCompletePollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + final nodeDevices = result.nodeDevices; + final externalDevices = result.externalDevices; + + expect(nodeDevices, isNotEmpty); + expect(externalDevices, isNotEmpty); + + // Master device should be a node + expect(nodeDevices.any((d) => d.isAuthority), isTrue); + + // External devices should not have nodeType + for (final device in externalDevices) { + expect(device.nodeType, isNull); + } + }); + + test('correctly populates wireless connections map', () { + // Arrange + final pollingData = DeviceManagerTestData.createCompletePollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result.wirelessConnections, isNotEmpty); + // Check that wireless connection contains expected data + final wirelessConnection = result.wirelessConnections.values.first; + expect(wirelessConnection.radioID, isNotNull); + expect(wirelessConnection.band, isNotNull); + }); + + test('correctly populates radio info map', () { + // Arrange + final pollingData = DeviceManagerTestData.createCompletePollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result.radioInfos, isNotEmpty); + expect(result.radioInfos.keys.any((k) => k.contains('5GHz')), isTrue); + }); + + test('returns empty state when polling data has empty device list', () { + // Arrange + final pollingData = DeviceManagerTestData.createEmptyPollingData(); + + // Act + final result = service.transformPollingData(pollingData); + + // Assert + expect(result, isA()); + expect(result.deviceList, isEmpty); + expect(result.wanStatus, isNotNull); // WAN status should still be present + }); + }); +} diff --git a/test/mocks/test_data/dashboard_home_test_data.dart b/test/mocks/test_data/dashboard_home_test_data.dart new file mode 100644 index 000000000..0931fb2b2 --- /dev/null +++ b/test/mocks/test_data/dashboard_home_test_data.dart @@ -0,0 +1,451 @@ +import 'package:privacy_gui/core/jnap/models/device.dart'; +import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; +import 'package:privacy_gui/core/jnap/models/radio_info.dart'; +import 'package:privacy_gui/core/jnap/models/wan_status.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; + +/// Test data builder for DashboardHomeService tests. +/// +/// Provides factory methods to create DashboardManagerState and DeviceManagerState +/// with sensible defaults for testing the service transformation logic. +/// +/// Per constitution Section 1.6.2 +class DashboardHomeTestData { + // ============================================ + // DashboardManagerState Builders + // ============================================ + + /// Create a DashboardManagerState with default values + static DashboardManagerState createDashboardManagerState({ + NodeDeviceInfo? deviceInfo, + List? mainRadios, + List? guestRadios, + bool isGuestNetworkEnabled = false, + int uptimes = 86400, + String? wanConnection = 'Linked-1000Mbps', + List lanConnections = const ['Linked-1000Mbps', 'None', 'None'], + }) { + return DashboardManagerState( + deviceInfo: deviceInfo ?? createNodeDeviceInfo(), + mainRadios: mainRadios ?? createDefaultMainRadios(), + guestRadios: guestRadios ?? const [], + isGuestNetworkEnabled: isGuestNetworkEnabled, + uptimes: uptimes, + wanConnection: wanConnection, + lanConnections: lanConnections, + ); + } + + /// Create DashboardManagerState with guest network enabled + static DashboardManagerState createDashboardManagerStateWithGuest({ + NodeDeviceInfo? deviceInfo, + List? mainRadios, + List? guestRadios, + int uptimes = 86400, + }) { + return createDashboardManagerState( + deviceInfo: deviceInfo, + mainRadios: mainRadios, + guestRadios: guestRadios ?? createDefaultGuestRadios(), + isGuestNetworkEnabled: true, + uptimes: uptimes, + ); + } + + /// Create empty DashboardManagerState (no radios) + static DashboardManagerState createEmptyDashboardManagerState() { + return const DashboardManagerState( + mainRadios: [], + guestRadios: [], + isGuestNetworkEnabled: false, + uptimes: 0, + lanConnections: [], + ); + } + + // ============================================ + // DeviceManagerState Builders + // ============================================ + + /// Create a DeviceManagerState with default values + static DeviceManagerState createDeviceManagerState({ + List? deviceList, + RouterWANStatus? wanStatus, + int lastUpdateTime = 1234567890, + }) { + return DeviceManagerState( + deviceList: deviceList ?? createDefaultDeviceList(), + wanStatus: wanStatus ?? createWanStatus(), + lastUpdateTime: lastUpdateTime, + ); + } + + /// Create DeviceManagerState for first polling (lastUpdateTime = 0) + static DeviceManagerState createFirstPollingDeviceManagerState({ + List? deviceList, + }) { + return createDeviceManagerState( + deviceList: deviceList, + lastUpdateTime: 0, + ); + } + + /// Create DeviceManagerState with offline nodes + static DeviceManagerState createDeviceManagerStateWithOfflineNodes() { + return createDeviceManagerState( + deviceList: [ + createMasterDevice(), + createSlaveDevice(isOnline: false), + ], + ); + } + + /// Create empty DeviceManagerState + static DeviceManagerState createEmptyDeviceManagerState() { + return const DeviceManagerState( + deviceList: [], + lastUpdateTime: 0, + ); + } + + // ============================================ + // RouterRadio Builders + // ============================================ + + /// Create a RouterRadio for testing + static RouterRadio createRouterRadio({ + String radioID = 'RADIO_2.4GHz', + String band = '2.4GHz', + String ssid = 'TestNetwork', + String passphrase = 'testpassword', + bool isEnabled = true, + }) { + return RouterRadio.fromMap({ + 'radioID': radioID, + 'physicalRadioID': 'wl0', + 'bssid': 'AA:BB:CC:DD:EE:01', + 'band': band, + 'supportedModes': const ['802.11b/g/n'], + 'supportedChannelsForChannelWidths': const [ + { + 'channelWidth': 'Auto', + 'channels': [1, 6, 11], + } + ], + 'supportedSecurityTypes': const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal' + ], + 'maxRADIUSSharedKeyLength': 64, + 'settings': { + 'isEnabled': isEnabled, + 'mode': '802.11b/g/n', + 'ssid': ssid, + 'broadcastSSID': true, + 'channelWidth': 'Auto', + 'channel': 6, + 'security': 'WPA2-Personal', + 'wpaPersonalSettings': { + 'passphrase': passphrase, + }, + }, + }); + } + + /// Create default main radios (2.4GHz and 5GHz) + static List createDefaultMainRadios() { + return [ + createRouterRadio( + radioID: 'RADIO_2.4GHz', + band: '2.4GHz', + ssid: 'TestNetwork', + passphrase: 'password123', + ), + createRouterRadio( + radioID: 'RADIO_5GHz', + band: '5GHz', + ssid: 'TestNetwork', + passphrase: 'password123', + ), + ]; + } + + // ============================================ + // GuestRadioInfo Builders + // ============================================ + + /// Create a GuestRadioInfo for testing + static GuestRadioInfo createGuestRadioInfo({ + String radioID = 'RADIO_2.4GHz', + String guestSSID = 'Guest-Network', + String? guestWPAPassphrase = 'guestpass', + bool isEnabled = true, + }) { + return GuestRadioInfo.fromMap({ + 'radioID': radioID, + 'isEnabled': isEnabled, + 'broadcastGuestSSID': true, + 'guestSSID': guestSSID, + 'guestWPAPassphrase': guestWPAPassphrase, + 'canEnableRadio': true, + }); + } + + /// Create default guest radios + static List createDefaultGuestRadios() { + return [ + createGuestRadioInfo( + radioID: 'RADIO_2.4GHz', + guestSSID: 'Guest-Network', + guestWPAPassphrase: 'guestpass123', + ), + createGuestRadioInfo( + radioID: 'RADIO_5GHz', + guestSSID: 'Guest-Network', + guestWPAPassphrase: 'guestpass123', + ), + ]; + } + + // ============================================ + // LinksysDevice Builders + // ============================================ + + /// Create a master device + static LinksysDevice createMasterDevice({ + String deviceId = 'master-device-001', + String friendlyName = 'Master Router', + String modelNumber = 'MX5300', + String hardwareVersion = '1', + bool isOnline = true, + }) { + return LinksysDevice( + deviceID: deviceId, + friendlyName: friendlyName, + isAuthority: true, + nodeType: 'Master', + lastChangeRevision: 1, + maxAllowedProperties: 10, + model: RawDeviceModel( + deviceType: 'Infrastructure', + manufacturer: 'Linksys', + modelNumber: modelNumber, + hardwareVersion: hardwareVersion, + ), + unit: const RawDeviceUnit( + serialNumber: 'SN123456789', + firmwareVersion: '1.0.0', + firmwareDate: '2024-01-01', + operatingSystem: 'Linux', + ), + connections: [ + RawDeviceConnection( + macAddress: 'AA:BB:CC:DD:EE:01', + ipAddress: isOnline ? '192.168.1.1' : null, + parentDeviceID: null, + isGuest: false, + ), + ], + properties: const [], + ); + } + + /// Create a slave device + static LinksysDevice createSlaveDevice({ + String deviceId = 'slave-device-001', + String friendlyName = 'Slave Node', + bool isOnline = true, + }) { + return LinksysDevice( + deviceID: deviceId, + friendlyName: friendlyName, + isAuthority: false, + nodeType: 'Slave', + lastChangeRevision: 1, + maxAllowedProperties: 10, + model: const RawDeviceModel( + deviceType: 'Infrastructure', + manufacturer: 'Linksys', + modelNumber: 'MX5300', + hardwareVersion: '1', + ), + unit: const RawDeviceUnit( + serialNumber: 'SN987654321', + firmwareVersion: '1.0.0', + firmwareDate: '2024-01-01', + operatingSystem: 'Linux', + ), + // isOnline() checks connections.isNotEmpty, so offline devices need empty connections + connections: isOnline + ? [ + RawDeviceConnection( + macAddress: 'AA:BB:CC:DD:EE:02', + ipAddress: '192.168.1.2', + parentDeviceID: 'master-device-001', + isGuest: false, + ), + ] + : const [], + properties: const [], + ); + } + + /// Create an external (client) device connected to main WiFi + static LinksysDevice createMainWifiDevice({ + String deviceId = 'external-device-001', + String friendlyName = 'iPhone', + String band = '5GHz', + bool isOnline = true, + }) { + return LinksysDevice( + deviceID: deviceId, + friendlyName: friendlyName, + isAuthority: false, + nodeType: null, + lastChangeRevision: 1, + maxAllowedProperties: 10, + connectedWifiType: WifiConnectionType.main, + model: const RawDeviceModel( + deviceType: 'Mobile', + manufacturer: 'Apple', + modelNumber: 'iPhone', + hardwareVersion: '1.0', + ), + unit: const RawDeviceUnit( + serialNumber: '', + firmwareVersion: '', + firmwareDate: '', + operatingSystem: 'iOS', + ), + connections: [ + RawDeviceConnection( + macAddress: 'AA:BB:CC:DD:EE:10', + ipAddress: isOnline ? '192.168.1.100' : null, + parentDeviceID: 'master-device-001', + isGuest: false, + ), + ], + knownInterfaces: [ + RawDeviceKnownInterface( + macAddress: 'AA:BB:CC:DD:EE:10', + interfaceType: 'Wireless', + band: band, + ), + ], + properties: const [], + ); + } + + /// Create an external device connected to guest WiFi + static LinksysDevice createGuestWifiDevice({ + String deviceId = 'guest-device-001', + String friendlyName = 'Guest iPhone', + bool isOnline = true, + }) { + return LinksysDevice( + deviceID: deviceId, + friendlyName: friendlyName, + isAuthority: false, + nodeType: null, + lastChangeRevision: 1, + maxAllowedProperties: 10, + connectedWifiType: WifiConnectionType.guest, + model: const RawDeviceModel( + deviceType: 'Mobile', + manufacturer: 'Apple', + modelNumber: 'iPhone', + hardwareVersion: '1.0', + ), + unit: const RawDeviceUnit( + serialNumber: '', + firmwareVersion: '', + firmwareDate: '', + operatingSystem: 'iOS', + ), + connections: [ + RawDeviceConnection( + macAddress: 'AA:BB:CC:DD:EE:20', + ipAddress: isOnline ? '192.168.2.100' : null, + parentDeviceID: 'master-device-001', + isGuest: true, + ), + ], + properties: const [], + ); + } + + /// Create default device list + static List createDefaultDeviceList() { + return [ + createMasterDevice(), + createMainWifiDevice(deviceId: 'device-001', band: '2.4GHz'), + createMainWifiDevice(deviceId: 'device-002', band: '5GHz'), + ]; + } + + // ============================================ + // Other Helpers + // ============================================ + + /// Create NodeDeviceInfo for testing + static NodeDeviceInfo createNodeDeviceInfo({ + String modelNumber = 'MX5300', + String hardwareVersion = '1', + String serialNumber = 'SN123456789', + }) { + return NodeDeviceInfo.fromJson({ + 'serialNumber': serialNumber, + 'modelNumber': modelNumber, + 'hardwareVersion': hardwareVersion, + 'manufacturer': 'Linksys', + 'description': 'Test Router', + 'firmwareVersion': '1.0.0', + 'firmwareDate': '2024-01-01T00:00:00Z', + 'services': ['http://linksys.com/jnap/core/Core'], + }); + } + + /// Create RouterWANStatus for testing + static RouterWANStatus createWanStatus({ + String wanType = 'DHCP', + String detectedWANType = 'DHCP', + String wanStatus = 'Connected', + }) { + return RouterWANStatus.fromMap({ + 'macAddress': 'AA:BB:CC:DD:EE:00', + 'detectedWANType': detectedWANType, + 'wanStatus': wanStatus, + 'wanIPv6Status': 'Disconnected', + 'supportedWANTypes': const ['DHCP', 'Static', 'PPPoE'], + 'supportedIPv6WANTypes': const [], + 'supportedWANCombinations': const >[], + 'wanConnection': { + 'wanType': wanType, + 'ipAddress': '192.168.1.100', + 'networkPrefixLength': 24, + 'gateway': '192.168.1.1', + 'mtu': 1500, + 'dnsServer1': '8.8.8.8', + 'dnsServer2': '8.8.4.4', + 'dnsServer3': null, + }, + }); + } + + /// Create a callback function for getBandForDevice that returns predictable values + static String Function(LinksysDevice) createGetBandForDeviceCallback({ + String defaultBand = '5GHz', + }) { + return (device) { + // Return band from knownInterfaces if available + final interface = device.knownInterfaces?.firstOrNull; + if (interface?.band != null) { + return interface!.band!; + } + return defaultBand; + }; + } +} diff --git a/test/mocks/test_data/dashboard_manager_test_data.dart b/test/mocks/test_data/dashboard_manager_test_data.dart new file mode 100644 index 000000000..66e0bf023 --- /dev/null +++ b/test/mocks/test_data/dashboard_manager_test_data.dart @@ -0,0 +1,280 @@ +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; + +/// Test data builder for DashboardManagerService tests. +/// +/// Provides factory methods to create JNAP mock responses with sensible defaults. +/// This centralizes test data and makes tests more readable. +/// +/// Per constitution Section 1.6.2 +class DashboardManagerTestData { + // === Individual JNAP Response Builders === + + /// Create default getDeviceInfo success response + static JNAPSuccess createDeviceInfoSuccess({ + String serialNumber = 'TEST123456', + String modelNumber = 'MX5300', + String firmwareVersion = '1.0.0', + String hardwareVersion = '1', + String manufacturer = 'Linksys', + String description = 'Test Router', + String firmwareDate = '2025-01-01T00:00:00Z', + }) => + JNAPSuccess( + result: 'OK', + output: { + 'serialNumber': serialNumber, + 'modelNumber': modelNumber, + 'firmwareVersion': firmwareVersion, + 'hardwareVersion': hardwareVersion, + 'manufacturer': manufacturer, + 'description': description, + 'firmwareDate': firmwareDate, + 'services': ['http://linksys.com/jnap/core/Core'], + }, + ); + + /// Create default getRadioInfo success response + static JNAPSuccess createRadioInfoSuccess({ + List>? radios, + bool isBandSteeringSupported = true, + }) => + JNAPSuccess( + result: 'OK', + output: { + 'isBandSteeringSupported': isBandSteeringSupported, + 'radios': radios ?? _defaultRadios, + }, + ); + + /// Default radios configuration matching the full RouterRadio structure + static List> get _defaultRadios => [ + { + 'radioID': 'RADIO_2.4GHz', + 'physicalRadioID': 'wl0', + 'bssid': 'AA:BB:CC:DD:EE:01', + 'band': '2.4GHz', + 'supportedModes': const ['802.11b/g/n'], + 'supportedChannelsForChannelWidths': const [ + { + 'channelWidth': 'Auto', + 'channels': [1, 6, 11], + } + ], + 'supportedSecurityTypes': const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal' + ], + 'maxRADIUSSharedKeyLength': 64, + 'settings': { + 'isEnabled': true, + 'mode': '802.11b/g/n', + 'ssid': 'TestNetwork', + 'broadcastSSID': true, + 'channelWidth': 'Auto', + 'channel': 6, + 'security': 'WPA2-Personal', + }, + }, + { + 'radioID': 'RADIO_5GHz', + 'physicalRadioID': 'wl1', + 'bssid': 'AA:BB:CC:DD:EE:02', + 'band': '5GHz', + 'supportedModes': const ['802.11a/n/ac'], + 'supportedChannelsForChannelWidths': const [ + { + 'channelWidth': 'Auto', + 'channels': [36, 40, 44, 48], + } + ], + 'supportedSecurityTypes': const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal' + ], + 'maxRADIUSSharedKeyLength': 64, + 'settings': { + 'isEnabled': true, + 'mode': '802.11a/n/ac', + 'ssid': 'TestNetwork', + 'broadcastSSID': true, + 'channelWidth': 'Auto', + 'channel': 36, + 'security': 'WPA2-Personal', + }, + }, + ]; + + /// Create default getGuestRadioSettings success response + static JNAPSuccess createGuestRadioSettingsSuccess({ + bool isGuestNetworkEnabled = false, + bool isGuestNetworkACaptivePortal = false, + List>? radios, + }) => + JNAPSuccess( + result: 'OK', + output: { + 'isGuestNetworkACaptivePortal': isGuestNetworkACaptivePortal, + 'isGuestNetworkEnabled': isGuestNetworkEnabled, + 'radios': radios ?? + [ + { + 'radioID': 'RADIO_2.4GHz', + 'isEnabled': false, + 'broadcastGuestSSID': true, + 'guestSSID': 'Guest-2.4GHz', + 'guestPassword': '', + 'canEnableRadio': true, + }, + { + 'radioID': 'RADIO_5GHz', + 'isEnabled': false, + 'broadcastGuestSSID': true, + 'guestSSID': 'Guest-5GHz', + 'guestPassword': '', + 'canEnableRadio': true, + }, + ], + }, + ); + + /// Create default getSystemStats success response + static JNAPSuccess createSystemStatsSuccess({ + int uptimeSeconds = 86400, + String? cpuLoad, + String? memoryLoad, + }) => + JNAPSuccess( + result: 'OK', + output: { + 'uptimeSeconds': uptimeSeconds, + if (cpuLoad != null) 'CPULoad': cpuLoad, + if (memoryLoad != null) 'MemoryLoad': memoryLoad, + }, + ); + + /// Create default getEthernetPortConnections success response + static JNAPSuccess createEthernetPortConnectionsSuccess({ + List lanPortConnections = const ['Linked-1000Mbps', 'None', 'None'], + String wanPortConnection = 'Linked-1000Mbps', + }) => + JNAPSuccess( + result: 'OK', + output: { + 'lanPortConnections': lanPortConnections, + 'wanPortConnection': wanPortConnection, + }, + ); + + /// Create default getLocalTime success response + static JNAPSuccess createLocalTimeSuccess({ + String? currentTime, + }) => + JNAPSuccess( + result: 'OK', + output: { + 'currentTime': currentTime ?? '2025-01-01T12:00:00Z', + }, + ); + + /// Create default getSoftSKUSettings success response + static JNAPSuccess createSoftSKUSettingsSuccess({ + String modelNumber = 'MX5300-SKU', + bool isSoftSKUEnabled = true, + }) => + JNAPSuccess( + result: 'OK', + output: { + 'modelNumber': modelNumber, + 'isSoftSKUEnabled': isSoftSKUEnabled, + }, + ); + + // === Combined Polling Data Builders === + + /// Create a complete successful polling data with all JNAP responses. + /// + /// Supports partial override design: only specify fields that need to change, + /// other fields use default values. + static CoreTransactionData createSuccessfulPollingData({ + JNAPSuccess? deviceInfo, + JNAPSuccess? radioInfo, + JNAPSuccess? guestRadioSettings, + JNAPSuccess? systemStats, + JNAPSuccess? ethernetPortConnections, + JNAPSuccess? localTime, + JNAPSuccess? softSKUSettings, + int? lastUpdate, + bool isReady = true, + }) { + final data = { + JNAPAction.getDeviceInfo: deviceInfo ?? createDeviceInfoSuccess(), + JNAPAction.getRadioInfo: radioInfo ?? createRadioInfoSuccess(), + JNAPAction.getGuestRadioSettings: + guestRadioSettings ?? createGuestRadioSettingsSuccess(), + JNAPAction.getSystemStats: systemStats ?? createSystemStatsSuccess(), + JNAPAction.getEthernetPortConnections: + ethernetPortConnections ?? createEthernetPortConnectionsSuccess(), + JNAPAction.getLocalTime: localTime ?? createLocalTimeSuccess(), + JNAPAction.getSoftSKUSettings: + softSKUSettings ?? createSoftSKUSettingsSuccess(), + }; + + return CoreTransactionData( + data: data, + lastUpdate: lastUpdate ?? DateTime.now().millisecondsSinceEpoch, + isReady: isReady, + ); + } + + /// Create polling data with some actions failed. + /// + /// [failedActions] - Set of JNAP actions that should return errors + static CoreTransactionData createPartialErrorPollingData({ + Set failedActions = const {}, + String errorMessage = 'Operation failed', + bool isReady = true, + }) { + final data = {}; + + // Add successful or failed results based on failedActions set + void addResult(JNAPAction action, JNAPSuccess successResult) { + if (failedActions.contains(action)) { + data[action] = JNAPError(result: 'ErrorUnknown', error: errorMessage); + } else { + data[action] = successResult; + } + } + + addResult(JNAPAction.getDeviceInfo, createDeviceInfoSuccess()); + addResult(JNAPAction.getRadioInfo, createRadioInfoSuccess()); + addResult( + JNAPAction.getGuestRadioSettings, createGuestRadioSettingsSuccess()); + addResult(JNAPAction.getSystemStats, createSystemStatsSuccess()); + addResult(JNAPAction.getEthernetPortConnections, + createEthernetPortConnectionsSuccess()); + addResult(JNAPAction.getLocalTime, createLocalTimeSuccess()); + addResult(JNAPAction.getSoftSKUSettings, createSoftSKUSettingsSuccess()); + + return CoreTransactionData( + data: data, + lastUpdate: DateTime.now().millisecondsSinceEpoch, + isReady: isReady, + ); + } + + /// Create polling data with invalid time format for testing fallback behavior + static CoreTransactionData createPollingDataWithInvalidTime() { + return createSuccessfulPollingData( + localTime: JNAPSuccess( + result: 'OK', + output: { + 'currentTime': 'invalid-time-format', + }, + ), + ); + } +} diff --git a/test/mocks/test_data/device_manager_test_data.dart b/test/mocks/test_data/device_manager_test_data.dart new file mode 100644 index 000000000..122d816b0 --- /dev/null +++ b/test/mocks/test_data/device_manager_test_data.dart @@ -0,0 +1,534 @@ +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; + +/// Test data builder for DeviceManagerService tests. +/// +/// Provides factory methods to create JNAP mock responses with sensible defaults. +/// Supports partial override pattern via named parameters. +/// +/// Usage: +/// ```dart +/// // Create complete polling data for service tests +/// final pollingData = DeviceManagerTestData.createCompletePollingData(); +/// +/// // Create with custom device list +/// final customPollingData = DeviceManagerTestData.createCompletePollingData( +/// devices: [DeviceManagerTestData.createMasterDevice()], +/// ); +/// ``` +class DeviceManagerTestData { + // ============================================ + // Individual JNAP Action Responses + // ============================================ + + /// Create successful getDevices JNAP response + static JNAPSuccess createGetDevicesSuccess({ + List>? devices, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'devices': devices ?? _defaultDeviceList, + }, + ); + } + + /// Create successful getNetworkConnections JNAP response + static JNAPSuccess createGetNetworkConnectionsSuccess({ + List>? connections, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'connections': connections ?? _defaultNetworkConnections, + }, + ); + } + + /// Create successful getNodesWirelessNetworkConnections JNAP response + static JNAPSuccess createGetNodesWirelessNetworkConnectionsSuccess({ + List>? nodeWirelessConnections, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'nodeWirelessConnections': + nodeWirelessConnections ?? _defaultNodeWirelessConnections, + }, + ); + } + + /// Create successful getRadioInfo JNAP response + static JNAPSuccess createGetRadioInfoSuccess({ + List>? radios, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'radios': radios ?? _defaultRadios, + }, + ); + } + + /// Create successful getGuestRadioSettings JNAP response + static JNAPSuccess createGetGuestRadioSettingsSuccess({ + bool isGuestNetworkEnabled = true, + bool isGuestNetworkACaptivePortal = false, + List>? radios, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'isGuestNetworkACaptivePortal': isGuestNetworkACaptivePortal, + 'isGuestNetworkEnabled': isGuestNetworkEnabled, + 'radios': radios ?? _defaultGuestRadios, + }, + ); + } + + /// Create successful getWANStatus JNAP response + static JNAPSuccess createGetWANStatusSuccess({ + String wanStatus = 'Connected', + String wanIPv6Status = 'Disconnected', + String detectedWANType = 'DHCP', + String macAddress = 'AA:BB:CC:DD:EE:00', + String? ipAddress, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'macAddress': macAddress, + 'detectedWANType': detectedWANType, + 'wanStatus': wanStatus, + 'wanIPv6Status': wanIPv6Status, + 'supportedWANTypes': const ['DHCP', 'Static', 'PPPoE'], + 'supportedIPv6WANTypes': const [], + 'supportedWANCombinations': const >[], + 'wanConnection': { + 'wanType': detectedWANType, + 'ipAddress': ipAddress ?? '192.168.1.100', + 'networkPrefixLength': 24, + 'gateway': '192.168.1.1', + 'mtu': 1500, + 'dnsServer1': '8.8.8.8', + 'dnsServer2': '8.8.4.4', + 'dnsServer3': null, + }, + }, + ); + } + + /// Create successful getBackhaulInfo JNAP response + static JNAPSuccess createGetBackhaulInfoSuccess({ + List>? backhaulDevices, + }) { + return JNAPSuccess( + result: 'OK', + output: { + 'backhaulDevices': backhaulDevices ?? _defaultBackhaulDevices, + }, + ); + } + + // ============================================ + // Complete Polling Data + // ============================================ + + /// Create complete CoreTransactionData for testing transformPollingData + static CoreTransactionData createCompletePollingData({ + List>? devices, + List>? connections, + List>? nodeWirelessConnections, + List>? radios, + List>? guestRadios, + List>? backhaulDevices, + String? wanStatus, + int lastUpdate = 1234567890, + bool isReady = true, + }) { + return CoreTransactionData( + lastUpdate: lastUpdate, + isReady: isReady, + data: { + JNAPAction.getDevices: createGetDevicesSuccess(devices: devices), + JNAPAction.getNetworkConnections: + createGetNetworkConnectionsSuccess(connections: connections), + JNAPAction.getNodesWirelessNetworkConnections: + createGetNodesWirelessNetworkConnectionsSuccess( + nodeWirelessConnections: nodeWirelessConnections, + ), + JNAPAction.getRadioInfo: createGetRadioInfoSuccess(radios: radios), + JNAPAction.getGuestRadioSettings: + createGetGuestRadioSettingsSuccess(radios: guestRadios), + JNAPAction.getWANStatus: + createGetWANStatusSuccess(wanStatus: wanStatus ?? 'Connected'), + JNAPAction.getBackhaulInfo: + createGetBackhaulInfoSuccess(backhaulDevices: backhaulDevices), + }, + ); + } + + /// Create null polling data (simulates initial load) + static CoreTransactionData? createNullPollingData() { + return null; + } + + /// Create partial error polling data (some actions failed) + static CoreTransactionData createPartialErrorPollingData({ + JNAPAction errorAction = JNAPAction.getBackhaulInfo, + String errorCode = 'ErrorUnknown', + int lastUpdate = 1234567890, + }) { + final Map data = { + JNAPAction.getDevices: createGetDevicesSuccess(), + JNAPAction.getNetworkConnections: createGetNetworkConnectionsSuccess(), + JNAPAction.getRadioInfo: createGetRadioInfoSuccess(), + JNAPAction.getWANStatus: createGetWANStatusSuccess(), + }; + + // Add the error action + data[errorAction] = JNAPError(result: errorCode, error: 'Test error'); + + return CoreTransactionData( + lastUpdate: lastUpdate, + isReady: true, + data: data, + ); + } + + /// Create empty polling data (no devices) + static CoreTransactionData createEmptyPollingData({ + int lastUpdate = 1234567890, + }) { + return CoreTransactionData( + lastUpdate: lastUpdate, + isReady: true, + data: { + JNAPAction.getDevices: const JNAPSuccess( + result: 'OK', + output: {'devices': >[]}, + ), + JNAPAction.getNetworkConnections: const JNAPSuccess( + result: 'OK', + output: {'connections': >[]}, + ), + JNAPAction.getRadioInfo: const JNAPSuccess( + result: 'OK', + output: {'radios': >[]}, + ), + JNAPAction.getWANStatus: createGetWANStatusSuccess(), + JNAPAction.getBackhaulInfo: const JNAPSuccess( + result: 'OK', + output: {'backhaulDevices': >[]}, + ), + }, + ); + } + + // ============================================ + // Device Factory Methods + // ============================================ + + /// Create a master node device + static Map createMasterDevice({ + String deviceId = 'master-device-id-001', + String friendlyName = 'Master Router', + String ipAddress = '192.168.1.1', + bool isOnline = true, + }) { + return { + 'deviceID': deviceId, + 'friendlyName': friendlyName, + 'isAuthority': true, + 'nodeType': 'Master', + 'model': { + 'deviceType': 'Infrastructure', + 'manufacturer': 'Linksys', + 'modelNumber': 'MX5300', + 'hardwareVersion': '1.0', + }, + 'unit': { + 'serialNumber': 'SN123456789', + 'firmwareVersion': '1.0.0', + 'firmwareDate': '2024-01-01', + 'operatingSystem': 'Linux', + }, + 'connections': [ + { + 'macAddress': 'AA:BB:CC:DD:EE:01', + 'ipAddress': ipAddress, + 'parentDeviceID': null, + 'isGuest': false, + } + ], + 'properties': [ + {'name': 'userDeviceName', 'value': friendlyName}, + ], + 'maxAllowedProperties': 10, + 'lastChangeRevision': 1, + 'knownInterfaces': [ + { + 'macAddress': 'AA:BB:CC:DD:EE:01', + 'interfaceType': 'Wired', + } + ], + }; + } + + /// Create a slave node device + static Map createSlaveDevice({ + String deviceId = 'slave-device-id-001', + String friendlyName = 'Slave Node', + String ipAddress = '192.168.1.2', + String parentDeviceId = 'master-device-id-001', + bool isOnline = true, + }) { + return { + 'deviceID': deviceId, + 'friendlyName': friendlyName, + 'isAuthority': false, + 'nodeType': 'Slave', + 'model': { + 'deviceType': 'Infrastructure', + 'manufacturer': 'Linksys', + 'modelNumber': 'MX5300', + 'hardwareVersion': '1.0', + }, + 'unit': { + 'serialNumber': 'SN987654321', + 'firmwareVersion': '1.0.0', + 'firmwareDate': '2024-01-01', + 'operatingSystem': 'Linux', + }, + 'connections': [ + { + 'macAddress': 'AA:BB:CC:DD:EE:02', + 'ipAddress': ipAddress, + 'parentDeviceID': parentDeviceId, + 'isGuest': false, + } + ], + 'properties': [ + {'name': 'userDeviceName', 'value': friendlyName}, + ], + 'maxAllowedProperties': 10, + 'lastChangeRevision': 1, + 'knownInterfaces': [ + { + 'macAddress': 'AA:BB:CC:DD:EE:02', + 'interfaceType': 'Wireless', + 'band': '5GHz', + } + ], + }; + } + + /// Create an external (client) device + static Map createExternalDevice({ + String deviceId = 'external-device-id-001', + String friendlyName = 'iPhone', + String macAddress = 'AA:BB:CC:DD:EE:10', + String ipAddress = '192.168.1.100', + String parentDeviceId = 'master-device-id-001', + bool isGuest = false, + }) { + return { + 'deviceID': deviceId, + 'friendlyName': friendlyName, + 'isAuthority': false, + 'nodeType': null, + 'model': { + 'deviceType': 'Mobile', + 'manufacturer': 'Apple', + 'modelNumber': 'iPhone', + 'hardwareVersion': '1.0', + }, + 'unit': { + 'serialNumber': '', + 'firmwareVersion': '', + 'firmwareDate': '', + 'operatingSystem': 'iOS', + }, + 'connections': [ + { + 'macAddress': macAddress, + 'ipAddress': ipAddress, + 'parentDeviceID': parentDeviceId, + 'isGuest': isGuest, + } + ], + 'properties': [ + {'name': 'userDeviceName', 'value': friendlyName}, + ], + 'maxAllowedProperties': 10, + 'lastChangeRevision': 1, + 'knownInterfaces': [ + { + 'macAddress': macAddress, + 'interfaceType': 'Wireless', + 'band': '5GHz', + } + ], + }; + } + + // ============================================ + // Error Responses + // ============================================ + + /// Create JNAP error response + static JNAPError createJnapError({ + String result = 'ErrorUnknown', + String? error, + }) { + return JNAPError( + result: result, + error: error ?? 'Operation failed', + ); + } + + // ============================================ + // Default Test Data + // ============================================ + + static List> get _defaultDeviceList => [ + createMasterDevice(), + createSlaveDevice(), + createExternalDevice(), + createExternalDevice( + deviceId: 'external-device-id-002', + friendlyName: 'MacBook', + macAddress: 'AA:BB:CC:DD:EE:11', + ipAddress: '192.168.1.101', + ), + ]; + + static List> get _defaultNetworkConnections => [ + { + 'macAddress': 'AA:BB:CC:DD:EE:10', + 'negotiatedMbps': 1000, + 'timestamp': '2024-01-01T00:00:00Z', + 'wireless': { + 'bssid': 'AA:BB:CC:DD:EE:01', + 'isGuest': false, + 'radioID': 'RADIO_5GHz', + 'band': '5GHz', + 'signalDecibels': -45, + }, + }, + { + 'macAddress': 'AA:BB:CC:DD:EE:11', + 'negotiatedMbps': 1000, + 'timestamp': '2024-01-01T00:00:00Z', + 'wireless': { + 'bssid': 'AA:BB:CC:DD:EE:01', + 'isGuest': false, + 'radioID': 'RADIO_5GHz', + 'band': '5GHz', + 'signalDecibels': -50, + }, + }, + ]; + + static List> get _defaultNodeWirelessConnections => [ + { + 'deviceID': 'master-device-id-001', + 'connections': _defaultNetworkConnections, + } + ]; + + static List> get _defaultRadios => [ + { + 'radioID': 'RADIO_2.4GHz', + 'physicalRadioID': 'wl0', + 'bssid': 'AA:BB:CC:DD:EE:01', + 'band': '2.4GHz', + 'supportedModes': const ['802.11b/g/n'], + 'supportedChannelsForChannelWidths': const [ + { + 'channelWidth': 'Auto', + 'channels': [1, 6, 11], + } + ], + 'supportedSecurityTypes': const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal' + ], + 'maxRADIUSSharedKeyLength': 64, + 'settings': { + 'isEnabled': true, + 'mode': '802.11b/g/n', + 'ssid': 'MyNetwork_2.4G', + 'broadcastSSID': true, + 'channelWidth': 'Auto', + 'channel': 6, + 'security': 'WPA2-Personal', + }, + }, + { + 'radioID': 'RADIO_5GHz', + 'physicalRadioID': 'wl1', + 'bssid': 'AA:BB:CC:DD:EE:02', + 'band': '5GHz', + 'supportedModes': const ['802.11a/n/ac'], + 'supportedChannelsForChannelWidths': const [ + { + 'channelWidth': 'Auto', + 'channels': [36, 40, 44, 48], + } + ], + 'supportedSecurityTypes': const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal' + ], + 'maxRADIUSSharedKeyLength': 64, + 'settings': { + 'isEnabled': true, + 'mode': '802.11a/n/ac', + 'ssid': 'MyNetwork_5G', + 'broadcastSSID': true, + 'channelWidth': 'Auto', + 'channel': 36, + 'security': 'WPA2-Personal', + }, + }, + ]; + + static List> get _defaultGuestRadios => [ + { + 'radioID': 'RADIO_2.4GHz', + 'isEnabled': true, + 'broadcastGuestSSID': true, + 'guestSSID': 'MyNetwork_Guest', + 'guestPassword': 'guestpass123', + }, + { + 'radioID': 'RADIO_5GHz', + 'isEnabled': true, + 'broadcastGuestSSID': true, + 'guestSSID': 'MyNetwork_Guest', + 'guestPassword': 'guestpass123', + }, + ]; + + static List> get _defaultBackhaulDevices => [ + { + 'deviceUUID': 'slave-device-id-001', + 'ipAddress': '192.168.1.2', + 'parentIPAddress': '192.168.1.1', + 'connectionType': 'Wireless', + 'speedMbps': '866', + 'timestamp': '2024-01-01T00:00:00Z', + 'wirelessConnectionInfo': { + 'radioID': '5GHz', + 'channel': 36, + 'apBSSID': 'AA:BB:CC:DD:EE:01', + 'stationBSSID': 'AA:BB:CC:DD:EE:02', + 'stationRSSI': -55, + }, + }, + ]; +} diff --git a/test/page/dashboard/providers/dashboard_home_provider_test.dart b/test/page/dashboard/providers/dashboard_home_provider_test.dart new file mode 100644 index 000000000..1b9100ffd --- /dev/null +++ b/test/page/dashboard/providers/dashboard_home_provider_test.dart @@ -0,0 +1,358 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_provider.dart'; +import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/dashboard/services/dashboard_home_service.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; + +import '../../../mocks/test_data/dashboard_home_test_data.dart'; + +/// Mock DashboardManagerNotifier for testing +class MockDashboardManagerNotifier extends Notifier + implements DashboardManagerNotifier { + final DashboardManagerState _state; + + MockDashboardManagerNotifier(this._state); + + @override + DashboardManagerState build() => _state; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Mock DeviceManagerNotifier for testing +class MockDeviceManagerNotifier extends Notifier + implements DeviceManagerNotifier { + final DeviceManagerState _state; + + MockDeviceManagerNotifier(this._state); + + @override + DeviceManagerState build() => _state; + + @override + String getBandConnectedBy(device) { + final interface = device.knownInterfaces?.firstOrNull; + if (interface?.band != null) { + return interface!.band!; + } + return '5GHz'; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Mock HealthCheckProvider for testing +class MockHealthCheckNotifier extends Notifier + implements HealthCheckProvider { + @override + HealthCheckState build() => HealthCheckState.init(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Mock DashboardHomeService for testing +class MockDashboardHomeService implements DashboardHomeService { + DashboardHomeState? returnState; + int buildCallCount = 0; + DashboardManagerState? lastDashboardManagerState; + DeviceManagerState? lastDeviceManagerState; + List? lastDeviceList; + + @override + DashboardHomeState buildDashboardHomeState({ + required DashboardManagerState dashboardManagerState, + required DeviceManagerState deviceManagerState, + required String Function(LinksysDevice device) getBandForDevice, + required List deviceList, + }) { + buildCallCount++; + lastDashboardManagerState = dashboardManagerState; + lastDeviceManagerState = deviceManagerState; + lastDeviceList = deviceList; + return returnState ?? const DashboardHomeState(); + } +} + +void main() { + late MockDashboardHomeService mockService; + late ProviderContainer container; + + setUp(() { + mockService = MockDashboardHomeService(); + }); + + tearDown(() { + container.dispose(); + }); + + group('DashboardHomeNotifier', () { + test('build() calls service.buildDashboardHomeState', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + + const expectedState = DashboardHomeState( + isFirstPolling: false, + masterIcon: 'routerMx5300', + uptime: 86400, + ); + mockService.returnState = expectedState; + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act + final state = container.read(dashboardHomeProvider); + + // Assert + expect(state, expectedState); + expect(mockService.buildCallCount, 1); + }); + + test('build() passes correct dashboardManagerState to service', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + uptimes: 172800, + wanConnection: 'Linked-100Mbps', + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act + container.read(dashboardHomeProvider); + + // Assert + expect(mockService.lastDashboardManagerState, dashboardManagerState); + expect(mockService.lastDashboardManagerState?.uptimes, 172800); + expect(mockService.lastDashboardManagerState?.wanConnection, + 'Linked-100Mbps'); + }); + + test('build() passes correct deviceManagerState to service', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = DashboardHomeTestData.createDeviceManagerState( + lastUpdateTime: 1234567890, + ); + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act + container.read(dashboardHomeProvider); + + // Assert + expect(mockService.lastDeviceManagerState, deviceManagerState); + expect(mockService.lastDeviceManagerState?.lastUpdateTime, 1234567890); + }); + + test('build() passes deviceList from deviceManagerProvider', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceList = [ + DashboardHomeTestData.createMasterDevice(), + DashboardHomeTestData.createMainWifiDevice(deviceId: 'device-001'), + DashboardHomeTestData.createMainWifiDevice(deviceId: 'device-002'), + ]; + final deviceManagerState = DashboardHomeTestData.createDeviceManagerState( + deviceList: deviceList, + ); + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act + container.read(dashboardHomeProvider); + + // Assert + expect(mockService.lastDeviceList, isNotNull); + expect(mockService.lastDeviceList!.length, 3); + }); + + test('returns service result directly', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + + const wifiItem = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + const expectedState = DashboardHomeState( + isFirstPolling: true, + isHorizontalLayout: true, + masterIcon: 'routerMx6200', + isAnyNodesOffline: true, + uptime: 86400, + wanPortConnection: 'Linked-1000Mbps', + lanPortConnections: ['Linked-1000Mbps', 'None', 'None'], + wifis: [wifiItem], + wanType: 'DHCP', + detectedWANType: 'DHCP', + ); + mockService.returnState = expectedState; + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act + final state = container.read(dashboardHomeProvider); + + // Assert + expect(state.isFirstPolling, true); + expect(state.isHorizontalLayout, true); + expect(state.masterIcon, 'routerMx6200'); + expect(state.isAnyNodesOffline, true); + expect(state.uptime, 86400); + expect(state.wanPortConnection, 'Linked-1000Mbps'); + expect(state.lanPortConnections, ['Linked-1000Mbps', 'None', 'None']); + expect(state.wifis.length, 1); + expect(state.wifis[0].ssid, 'TestNetwork'); + expect(state.wanType, 'DHCP'); + expect(state.detectedWANType, 'DHCP'); + }); + + test('provider listens to dashboardManagerProvider changes', () { + // Arrange + final dashboardManagerState1 = + DashboardHomeTestData.createDashboardManagerState(uptimes: 1000); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState1)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act - read provider to trigger build + container.read(dashboardHomeProvider); + + // Assert - service was called + expect(mockService.buildCallCount, 1); + expect(mockService.lastDashboardManagerState?.uptimes, 1000); + }); + + test('provider listens to deviceManagerProvider changes', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = DashboardHomeTestData.createDeviceManagerState( + lastUpdateTime: 0, // First polling + ); + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act + container.read(dashboardHomeProvider); + + // Assert + expect(mockService.buildCallCount, 1); + expect(mockService.lastDeviceManagerState?.lastUpdateTime, 0); + }); + + test('provider listens to healthCheckProvider (for reactivity)', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + + container = ProviderContainer( + overrides: [ + dashboardHomeServiceProvider.overrideWithValue(mockService), + dashboardManagerProvider.overrideWith( + () => MockDashboardManagerNotifier(dashboardManagerState)), + deviceManagerProvider.overrideWith( + () => MockDeviceManagerNotifier(deviceManagerState)), + healthCheckProvider.overrideWith(() => MockHealthCheckNotifier()), + ], + ); + + // Act - just verify the provider builds without error + // healthCheckProvider is watched but not used directly + final state = container.read(dashboardHomeProvider); + + // Assert + expect(state, isA()); + }); + }); +} diff --git a/test/page/dashboard/providers/dashboard_home_state_test.dart b/test/page/dashboard/providers/dashboard_home_state_test.dart new file mode 100644 index 000000000..3d5991f0b --- /dev/null +++ b/test/page/dashboard/providers/dashboard_home_state_test.dart @@ -0,0 +1,835 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; + +void main() { + group('DashboardSpeedUIModel', () { + group('construction & defaults', () { + test('creates instance with required fields', () { + // Act + const item = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Assert + expect(item.unit, 'Mbps'); + expect(item.value, '100'); + }); + }); + + group('copyWith', () { + test('creates new instance (not same reference)', () { + // Arrange + const item = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Act + final copied = item.copyWith(value: '200'); + + // Assert + expect(identical(copied, item), false); + expect(copied.value, '200'); + expect(item.value, '100'); + }); + + test('preserves unmodified fields', () { + // Arrange + const item = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Act + final copied = item.copyWith(value: '200'); + + // Assert + expect(copied.unit, 'Mbps'); + expect(copied.value, '200'); + }); + + test('updates unit', () { + // Arrange + const item = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Act + final updated = item.copyWith(unit: 'Kbps'); + + // Assert + expect(updated.unit, 'Kbps'); + expect(updated.value, '100'); + }); + }); + + group('equality (Equatable)', () { + test('identical items are equal', () { + // Arrange + const item1 = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + const item2 = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Assert + expect(item1, item2); + expect(item1.hashCode, item2.hashCode); + }); + + test('different items are not equal', () { + // Arrange + const item1 = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + const item2 = DashboardSpeedUIModel(unit: 'Mbps', value: '200'); + + // Assert + expect(item1, isNot(item2)); + }); + }); + + group('serialization', () { + test('toMap includes all fields', () { + // Arrange + const item = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Act + final map = item.toMap(); + + // Assert + expect(map['unit'], 'Mbps'); + expect(map['value'], '100'); + expect(map.length, 2); + }); + + test('fromMap restores all fields correctly', () { + // Arrange + const original = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + final map = original.toMap(); + + // Act + final restored = DashboardSpeedUIModel.fromMap(map); + + // Assert + expect(restored, original); + }); + + test('toJson returns valid JSON string', () { + // Arrange + const item = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + + // Act + final json = item.toJson(); + + // Assert + expect(json, isNotNull); + expect(json, isA()); + final decoded = jsonDecode(json); + expect(decoded, isA()); + expect(decoded['unit'], 'Mbps'); + expect(decoded['value'], '100'); + }); + + test('fromJson correctly parses JSON string', () { + // Arrange + const original = DashboardSpeedUIModel(unit: 'Mbps', value: '100'); + final json = original.toJson(); + + // Act + final restored = DashboardSpeedUIModel.fromJson(json); + + // Assert + expect(restored, original); + }); + }); + }); + + group('DashboardWiFiUIModel', () { + group('construction & defaults', () { + test('creates instance with all required fields', () { + // Act + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Assert + expect(item.ssid, 'TestNetwork'); + expect(item.password, 'password123'); + expect(item.radios, ['RADIO_2.4GHz', 'RADIO_5GHz']); + expect(item.isGuest, false); + expect(item.isEnabled, true); + expect(item.numOfConnectedDevices, 5); + }); + + test('creates guest network item', () { + // Act + const item = DashboardWiFiUIModel( + ssid: 'Guest-Network', + password: 'guestpass', + radios: ['RADIO_2.4GHz'], + isGuest: true, + isEnabled: true, + numOfConnectedDevices: 2, + ); + + // Assert + expect(item.ssid, 'Guest-Network'); + expect(item.isGuest, true); + }); + }); + + group('copyWith', () { + test('creates new instance (not same reference)', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final copied = item.copyWith(ssid: 'NewNetwork'); + + // Assert + expect(identical(copied, item), false); + expect(copied.ssid, 'NewNetwork'); + expect(item.ssid, 'TestNetwork'); + }); + + test('preserves unmodified fields', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final copied = item.copyWith(ssid: 'NewNetwork'); + + // Assert + expect(copied.ssid, 'NewNetwork'); + expect(copied.password, 'password123'); + expect(copied.radios, ['RADIO_2.4GHz', 'RADIO_5GHz']); + expect(copied.isGuest, false); + expect(copied.isEnabled, true); + expect(copied.numOfConnectedDevices, 5); + }); + + test('updates password', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final updated = item.copyWith(password: 'newpassword'); + + // Assert + expect(updated.password, 'newpassword'); + }); + + test('updates isEnabled', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final updated = item.copyWith(isEnabled: false); + + // Assert + expect(updated.isEnabled, false); + }); + + test('updates numOfConnectedDevices', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final updated = item.copyWith(numOfConnectedDevices: 10); + + // Assert + expect(updated.numOfConnectedDevices, 10); + }); + + test('updates multiple fields at once', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final updated = item.copyWith( + ssid: 'NewNetwork', + password: 'newpass', + isEnabled: false, + ); + + // Assert + expect(updated.ssid, 'NewNetwork'); + expect(updated.password, 'newpass'); + expect(updated.isEnabled, false); + expect(updated.isGuest, false); + expect(updated.numOfConnectedDevices, 5); + }); + }); + + group('equality (Equatable)', () { + test('identical items are equal', () { + // Arrange + const item1 = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + const item2 = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Assert + expect(item1, item2); + expect(item1.hashCode, item2.hashCode); + }); + + test('different ssid makes items unequal', () { + // Arrange + const item1 = DashboardWiFiUIModel( + ssid: 'TestNetwork1', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + const item2 = DashboardWiFiUIModel( + ssid: 'TestNetwork2', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Assert + expect(item1, isNot(item2)); + }); + + test('different isGuest makes items unequal', () { + // Arrange + const item1 = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + const item2 = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: true, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Assert + expect(item1, isNot(item2)); + }); + }); + + group('serialization', () { + test('toMap includes all fields', () { + // Arrange + const item = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + final map = item.toMap(); + + // Assert + expect(map['ssid'], 'TestNetwork'); + expect(map['password'], 'password123'); + expect(map['radios'], ['RADIO_2.4GHz', 'RADIO_5GHz']); + expect(map['isGuest'], false); + expect(map['isEnabled'], true); + expect(map['numOfConnectedDevices'], 5); + expect(map.length, 6); + }); + + test('fromMap restores all fields correctly', () { + // Arrange + const original = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + final map = original.toMap(); + + // Act + final restored = DashboardWiFiUIModel.fromMap(map); + + // Assert + expect(restored, original); + }); + + test('round-trip: object → toJson() → fromJson() → equals(original)', () { + // Arrange + const original = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz', 'RADIO_5GHz'], + isGuest: true, + isEnabled: false, + numOfConnectedDevices: 3, + ); + + // Act + final json = original.toJson(); + final restored = DashboardWiFiUIModel.fromJson(json); + + // Assert + expect(restored, original); + }); + }); + }); + + group('DashboardHomeState', () { + group('construction & defaults', () { + test('creates instance with default values', () { + // Act + const state = DashboardHomeState(); + + // Assert + expect(state.isFirstPolling, false); + expect(state.isHorizontalLayout, false); + expect(state.masterIcon, ''); + expect(state.isAnyNodesOffline, false); + expect(state.uptime, isNull); + expect(state.wanPortConnection, isNull); + expect(state.lanPortConnections, const []); + expect(state.wifis, const []); + expect(state.wanType, isNull); + expect(state.detectedWANType, isNull); + }); + + test('creates instance with all fields provided', () { + // Arrange + const wifiItem = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + + // Act + const state = DashboardHomeState( + isFirstPolling: true, + isHorizontalLayout: true, + masterIcon: 'routerMx5300', + isAnyNodesOffline: true, + uptime: 86400, + wanPortConnection: 'Linked-1000Mbps', + lanPortConnections: ['Linked-1000Mbps', 'None', 'None'], + wifis: [wifiItem], + wanType: 'DHCP', + detectedWANType: 'DHCP', + ); + + // Assert + expect(state.isFirstPolling, true); + expect(state.isHorizontalLayout, true); + expect(state.masterIcon, 'routerMx5300'); + expect(state.isAnyNodesOffline, true); + expect(state.uptime, 86400); + expect(state.wanPortConnection, 'Linked-1000Mbps'); + expect(state.lanPortConnections, ['Linked-1000Mbps', 'None', 'None']); + expect(state.wifis, [wifiItem]); + expect(state.wanType, 'DHCP'); + expect(state.detectedWANType, 'DHCP'); + }); + }); + + group('copyWith', () { + test('updates isFirstPolling', () { + // Arrange + const state = DashboardHomeState(isFirstPolling: false); + + // Act + final updated = state.copyWith(isFirstPolling: true); + + // Assert + expect(updated.isFirstPolling, true); + }); + + test('updates isHorizontalLayout', () { + // Arrange + const state = DashboardHomeState(isHorizontalLayout: false); + + // Act + final updated = state.copyWith(isHorizontalLayout: true); + + // Assert + expect(updated.isHorizontalLayout, true); + }); + + test('updates masterIcon', () { + // Arrange + const state = DashboardHomeState(masterIcon: 'routerMx5300'); + + // Act + final updated = state.copyWith(masterIcon: 'routerMx6200'); + + // Assert + expect(updated.masterIcon, 'routerMx6200'); + }); + + test('updates isAnyNodesOffline', () { + // Arrange + const state = DashboardHomeState(isAnyNodesOffline: false); + + // Act + final updated = state.copyWith(isAnyNodesOffline: true); + + // Assert + expect(updated.isAnyNodesOffline, true); + }); + + test('updates uptime with ValueGetter', () { + // Arrange + const state = DashboardHomeState(uptime: 1000); + + // Act + final updated = state.copyWith(uptime: () => 2000); + + // Assert + expect(updated.uptime, 2000); + }); + + test('updates uptime to null with ValueGetter', () { + // Arrange + const state = DashboardHomeState(uptime: 1000); + + // Act + final updated = state.copyWith(uptime: () => null); + + // Assert + expect(updated.uptime, isNull); + }); + + test('updates wanPortConnection', () { + // Arrange + const state = DashboardHomeState(wanPortConnection: 'Linked-100Mbps'); + + // Act + final updated = + state.copyWith(wanPortConnection: () => 'Linked-1000Mbps'); + + // Assert + expect(updated.wanPortConnection, 'Linked-1000Mbps'); + }); + + test('updates lanPortConnections', () { + // Arrange + const state = DashboardHomeState(lanPortConnections: ['None', 'None']); + + // Act + final updated = state.copyWith( + lanPortConnections: ['Linked-1000Mbps', 'None', 'None', 'None'], + ); + + // Assert + expect(updated.lanPortConnections.length, 4); + expect(updated.lanPortConnections[0], 'Linked-1000Mbps'); + }); + + test('updates wifis list', () { + // Arrange + const state = DashboardHomeState(wifis: []); + const newWifi = DashboardWiFiUIModel( + ssid: 'NewNetwork', + password: 'pass', + radios: ['RADIO_5GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 3, + ); + + // Act + final updated = state.copyWith(wifis: [newWifi]); + + // Assert + expect(updated.wifis.length, 1); + expect(updated.wifis[0].ssid, 'NewNetwork'); + }); + + test('updates wanType', () { + // Arrange + const state = DashboardHomeState(wanType: 'DHCP'); + + // Act + final updated = state.copyWith(wanType: () => 'PPPoE'); + + // Assert + expect(updated.wanType, 'PPPoE'); + }); + + test('updates detectedWANType', () { + // Arrange + const state = DashboardHomeState(detectedWANType: 'DHCP'); + + // Act + final updated = state.copyWith(detectedWANType: () => 'Bridge'); + + // Assert + expect(updated.detectedWANType, 'Bridge'); + }); + + test('preserves unmodified fields', () { + // Arrange + const state = DashboardHomeState( + isFirstPolling: true, + isHorizontalLayout: true, + masterIcon: 'routerMx5300', + uptime: 86400, + ); + + // Act + final updated = state.copyWith(isFirstPolling: false); + + // Assert + expect(updated.isFirstPolling, false); + expect(updated.isHorizontalLayout, true); + expect(updated.masterIcon, 'routerMx5300'); + expect(updated.uptime, 86400); + }); + }); + + group('equality (Equatable)', () { + test('identical states are equal', () { + // Arrange + const state1 = DashboardHomeState( + isFirstPolling: true, + masterIcon: 'routerMx5300', + uptime: 86400, + ); + const state2 = DashboardHomeState( + isFirstPolling: true, + masterIcon: 'routerMx5300', + uptime: 86400, + ); + + // Assert + expect(state1, state2); + expect(state1.hashCode, state2.hashCode); + }); + + test('different states are not equal', () { + // Arrange + const state1 = DashboardHomeState(isFirstPolling: true); + const state2 = DashboardHomeState(isFirstPolling: false); + + // Assert + expect(state1, isNot(state2)); + }); + }); + + group('serialization', () { + test('toMap includes all fields', () { + // Arrange + const state = DashboardHomeState( + isFirstPolling: true, + isHorizontalLayout: true, + masterIcon: 'routerMx5300', + isAnyNodesOffline: false, + uptime: 86400, + wanPortConnection: 'Linked-1000Mbps', + lanPortConnections: ['Linked-1000Mbps', 'None'], + wifis: [], + wanType: 'DHCP', + detectedWANType: 'DHCP', + ); + + // Act + final map = state.toMap(); + + // Assert + expect(map['isFirstPolling'], true); + expect(map['isHorizontalLayout'], true); + expect(map['masterIcon'], 'routerMx5300'); + expect(map['isAnyNodesOffline'], false); + expect(map['uptime'], 86400); + expect(map['wanPortConnection'], 'Linked-1000Mbps'); + expect(map['lanPortConnections'], ['Linked-1000Mbps', 'None']); + expect(map['wifis'], []); + expect(map['wanType'], 'DHCP'); + expect(map['detectedWANType'], 'DHCP'); + }); + + test('fromMap restores all fields correctly', () { + // Arrange + const wifiItem = DashboardWiFiUIModel( + ssid: 'TestNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + const originalState = DashboardHomeState( + isFirstPolling: true, + isHorizontalLayout: true, + masterIcon: 'routerMx5300', + isAnyNodesOffline: true, + uptime: 86400, + wanPortConnection: 'Linked-1000Mbps', + lanPortConnections: ['Linked-1000Mbps', 'None'], + wifis: [wifiItem], + wanType: 'DHCP', + detectedWANType: 'DHCP', + ); + final map = originalState.toMap(); + + // Act + final restored = DashboardHomeState.fromMap(map); + + // Assert + expect(restored.isFirstPolling, originalState.isFirstPolling); + expect(restored.isHorizontalLayout, originalState.isHorizontalLayout); + expect(restored.masterIcon, originalState.masterIcon); + expect(restored.isAnyNodesOffline, originalState.isAnyNodesOffline); + expect(restored.uptime, originalState.uptime); + expect(restored.wanPortConnection, originalState.wanPortConnection); + expect(restored.lanPortConnections, originalState.lanPortConnections); + expect(restored.wifis.length, originalState.wifis.length); + expect(restored.wanType, originalState.wanType); + expect(restored.detectedWANType, originalState.detectedWANType); + }); + + test('round-trip: object → toJson() → fromJson() → equals(original)', () { + // Arrange + const originalState = DashboardHomeState( + isFirstPolling: false, + isHorizontalLayout: true, + masterIcon: 'routerMx5300', + isAnyNodesOffline: false, + uptime: 172800, + wanPortConnection: 'Linked-1000Mbps', + lanPortConnections: ['Linked-1000Mbps', 'None', 'None', 'None'], + wifis: [], + wanType: 'DHCP', + detectedWANType: 'DHCP', + ); + + // Act + final json = originalState.toJson(); + final restored = DashboardHomeState.fromJson(json); + + // Assert + expect(restored, originalState); + }); + }); + }); + + group('DashboardHomeStateExt', () { + test('mainSSID returns first WiFi SSID', () { + // Arrange + const wifiItem = DashboardWiFiUIModel( + ssid: 'MyNetwork', + password: 'password123', + radios: ['RADIO_2.4GHz'], + isGuest: false, + isEnabled: true, + numOfConnectedDevices: 5, + ); + const state = DashboardHomeState(wifis: [wifiItem]); + + // Act & Assert + expect(state.mainSSID, 'MyNetwork'); + }); + + test('mainSSID returns empty string when no WiFi', () { + // Arrange + const state = DashboardHomeState(wifis: []); + + // Act & Assert + expect(state.mainSSID, ''); + }); + + test('isBridgeMode returns true when wanType is Bridge', () { + // Arrange + const state = DashboardHomeState(wanType: 'Bridge'); + + // Act & Assert + expect(state.isBridgeMode, true); + }); + + test('isBridgeMode returns true when detectedWANType is Bridge', () { + // Arrange + const state = DashboardHomeState(detectedWANType: 'Bridge'); + + // Act & Assert + expect(state.isBridgeMode, true); + }); + + test('isBridgeMode returns false when neither is Bridge', () { + // Arrange + const state = DashboardHomeState( + wanType: 'DHCP', + detectedWANType: 'DHCP', + ); + + // Act & Assert + expect(state.isBridgeMode, false); + }); + + test('isBridgeMode returns false when both are null', () { + // Arrange + const state = DashboardHomeState(); + + // Act & Assert + expect(state.isBridgeMode, false); + }); + }); +} diff --git a/test/page/dashboard/services/dashboard_home_service_test.dart b/test/page/dashboard/services/dashboard_home_service_test.dart new file mode 100644 index 000000000..11f7aa575 --- /dev/null +++ b/test/page/dashboard/services/dashboard_home_service_test.dart @@ -0,0 +1,452 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/dashboard/services/dashboard_home_service.dart'; + +import '../../../mocks/test_data/dashboard_home_test_data.dart'; + +void main() { + late DashboardHomeService service; + + setUp(() { + service = const DashboardHomeService(); + }); + + group('DashboardHomeService', () { + group('buildDashboardHomeState', () { + test('returns correct state with main WiFi networks grouped by band', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + mainRadios: DashboardHomeTestData.createDefaultMainRadios(), + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + deviceList: [ + DashboardHomeTestData.createMasterDevice(), + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-001', + band: '2.4GHz', + ), + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-002', + band: '5GHz', + ), + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-003', + band: '5GHz', + ), + ], + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.wifis.length, 2); // Two bands: 2.4GHz and 5GHz + expect(result.wifis.any((wifi) => wifi.ssid == 'TestNetwork'), true); + expect(result.wifis.every((wifi) => !wifi.isGuest), true); + + // Verify WiFi items have correct structure + for (final wifi in result.wifis) { + expect(wifi.ssid.isNotEmpty, true); + expect(wifi.radios.isNotEmpty, true); + } + }); + + test('returns correct state with guest WiFi when guest radios exist', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerStateWithGuest(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + deviceList: [ + DashboardHomeTestData.createMasterDevice(), + DashboardHomeTestData.createMainWifiDevice(deviceId: 'device-001'), + DashboardHomeTestData.createGuestWifiDevice( + deviceId: 'guest-device-001'), + ], + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + final guestWifi = + result.wifis.where((wifi) => wifi.isGuest).firstOrNull; + expect(guestWifi, isNotNull); + expect(guestWifi!.ssid, 'Guest-Network'); + expect(guestWifi.isGuest, true); + expect(guestWifi.isEnabled, true); + }); + + test('returns empty WiFi list when no radios exist', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createEmptyDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createEmptyDeviceManagerState(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.wifis, isEmpty); + }); + + test('sets isAnyNodesOffline true when nodes are offline', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerStateWithOfflineNodes(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.isAnyNodesOffline, true); + }); + + test('sets isAnyNodesOffline false when all nodes are online', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + deviceList: [ + DashboardHomeTestData.createMasterDevice(isOnline: true), + DashboardHomeTestData.createSlaveDevice(isOnline: true), + ], + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.isAnyNodesOffline, false); + }); + + test('sets isFirstPolling true when lastUpdateTime is zero', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createFirstPollingDeviceManagerState(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.isFirstPolling, true); + }); + + test('sets isFirstPolling false when lastUpdateTime is non-zero', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + lastUpdateTime: 1234567890, + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.isFirstPolling, false); + }); + + test('handles null deviceInfo for port layout determination', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + deviceInfo: null, + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + // Should not throw and should return a valid state + expect(result, isA()); + // isHorizontalLayout should have a default value (false) when deviceInfo is null + expect(result.isHorizontalLayout, isA()); + }); + + // ============================================ + // User Story 2 Tests (T020-T026) + // ============================================ + + test('correctly counts connected devices per band', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + mainRadios: DashboardHomeTestData.createDefaultMainRadios(), + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + deviceList: [ + DashboardHomeTestData.createMasterDevice(), + // 2 devices on 2.4GHz + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-001', + band: '2.4GHz', + ), + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-002', + band: '2.4GHz', + ), + // 3 devices on 5GHz + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-003', + band: '5GHz', + ), + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-004', + band: '5GHz', + ), + DashboardHomeTestData.createMainWifiDevice( + deviceId: 'device-005', + band: '5GHz', + ), + ], + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + final wifi24 = result.wifis.firstWhere( + (wifi) => wifi.radios.contains('RADIO_2.4GHz'), + ); + final wifi5 = result.wifis.firstWhere( + (wifi) => wifi.radios.contains('RADIO_5GHz'), + ); + expect(wifi24.numOfConnectedDevices, 2); + expect(wifi5.numOfConnectedDevices, 3); + }); + + test('does not add guest WiFi when guest radios are empty', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + guestRadios: const [], + isGuestNetworkEnabled: + true, // Even if enabled, no radios = no guest WiFi + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.wifis.any((wifi) => wifi.isGuest), false); + }); + + test('correctly extracts WAN type from wanStatus', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + wanStatus: DashboardHomeTestData.createWanStatus(wanType: 'PPPoE'), + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.wanType, 'PPPoE'); + }); + + test('correctly extracts detectedWANType from wanStatus', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + wanStatus: DashboardHomeTestData.createWanStatus( + detectedWANType: 'Bridge', + ), + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.detectedWANType, 'Bridge'); + }); + + test('correctly determines master icon from deviceList', () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState(); + final deviceList = [ + DashboardHomeTestData.createMasterDevice(modelNumber: 'MX5300'), + ]; + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState( + deviceList: deviceList, + ); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceList, + ); + + // Assert + // MX5300 should return a router icon (routerMx5300 -> routerMx5300) + expect(result.masterIcon.isNotEmpty, true); + expect(result.masterIcon, isNot('node')); + }); + + test('correctly determines horizontal port layout', () { + // Arrange - LN11 has horizontal ports + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + deviceInfo: DashboardHomeTestData.createNodeDeviceInfo( + modelNumber: 'LN11', + hardwareVersion: '1', + ), + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.isHorizontalLayout, true); + }); + + test('passes through uptime, wanConnection, lanConnections correctly', + () { + // Arrange + final dashboardManagerState = + DashboardHomeTestData.createDashboardManagerState( + uptimes: 172800, // 2 days in seconds + wanConnection: 'Linked-100Mbps', + lanConnections: ['Linked-1000Mbps', 'Linked-100Mbps', 'None', 'None'], + ); + final deviceManagerState = + DashboardHomeTestData.createDeviceManagerState(); + final getBandForDevice = + DashboardHomeTestData.createGetBandForDeviceCallback(); + + // Act + final result = service.buildDashboardHomeState( + dashboardManagerState: dashboardManagerState, + deviceManagerState: deviceManagerState, + getBandForDevice: getBandForDevice, + deviceList: deviceManagerState.deviceList, + ); + + // Assert + expect(result.uptime, 172800); + expect(result.wanPortConnection, 'Linked-100Mbps'); + expect(result.lanPortConnections.length, 4); + expect(result.lanPortConnections[0], 'Linked-1000Mbps'); + expect(result.lanPortConnections[1], 'Linked-100Mbps'); + }); + }); + }); +}