Skip to content

Commit 0dc5369

Browse files
committed
feat: Enhance DeviceDiscoveryViewModel for testability with isMobileOverride and expose bleScanFuture
1 parent 45b74a6 commit 0dc5369

6 files changed

Lines changed: 200 additions & 32 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import 'dart:io' show Platform;
2+
import 'package:flutter/foundation.dart';
3+
4+
/// Platform-related utilities exposed via dependency injection.
5+
///
6+
/// This interface exists so that tests can substitute a fake implementation
7+
/// if desired and to avoid sprinkling `Platform`/`kIsWeb` checks throughout
8+
/// the codebase.
9+
abstract class PlatformService {
10+
/// `true` when running in a web browser.
11+
bool get isWeb;
12+
13+
/// Conventional mobile platforms.
14+
bool get isAndroid;
15+
bool get isIOS;
16+
17+
/// Conventional desktop platforms.
18+
bool get isWindows;
19+
bool get isMacOS;
20+
bool get isLinux;
21+
22+
/// Convenience helpers.
23+
bool get isMobile; // android || ios
24+
bool get isDesktop; // windows || macos || linux
25+
}
26+
27+
/// Default implementation that delegates to Flutter's and Dart's
28+
/// built‑in platform checks.
29+
class PlatformServiceImpl implements PlatformService {
30+
@override
31+
bool get isWeb => kIsWeb;
32+
33+
@override
34+
bool get isAndroid => !kIsWeb && Platform.isAndroid;
35+
36+
@override
37+
bool get isIOS => !kIsWeb && Platform.isIOS;
38+
39+
@override
40+
bool get isWindows => !kIsWeb && Platform.isWindows;
41+
42+
@override
43+
bool get isMacOS => !kIsWeb && Platform.isMacOS;
44+
45+
@override
46+
bool get isLinux => !kIsWeb && Platform.isLinux;
47+
48+
@override
49+
bool get isMobile => isAndroid || isIOS;
50+
51+
@override
52+
bool get isDesktop => isWindows || isMacOS || isLinux;
53+
}

client/lib/features/devices/view_models/device_discovery_view_model.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:cancellation_token/cancellation_token.dart';
99
import 'package:flutter/foundation.dart';
1010
import 'package:logger/logger.dart';
1111
import 'package:permission_handler/permission_handler.dart';
12+
import 'package:borneo_app/core/services/platform_service.dart';
1213

1314
import '../models/events.dart';
1415
import 'package:borneo_app/core/services/devices/device_manager.dart';
@@ -25,6 +26,7 @@ class DeviceDiscoveryViewModel extends AbstractScreenViewModel {
2526
final IDeviceManager _deviceManager;
2627
final IBleProvisioner _bleProvisioner;
2728
final IDeviceModuleRegistry deviceMdoules;
29+
final PlatformService _platformService;
2830

2931
bool get _isDiscovering => _deviceManager.isDiscoverying;
3032
bool _isRefreshing = false;
@@ -50,13 +52,14 @@ class DeviceDiscoveryViewModel extends AbstractScreenViewModel {
5052
int get remainingSeconds => _remainingSeconds;
5153

5254
// Platform check
53-
bool get isMobile => defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS;
55+
bool get isMobile => _platformService.isMobile;
5456

5557
DeviceDiscoveryViewModel(
5658
this._logger,
5759
this._deviceManager,
5860
this._bleProvisioner,
59-
this.deviceMdoules, {
61+
this.deviceMdoules,
62+
this._platformService, {
6063
required super.globalEventBus,
6164
required super.gt,
6265
super.logger,

client/lib/features/devices/views/device_discovery_screen.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:borneo_app/features/devices/models/discoverable_device.dart';
22
import 'package:borneo_app/features/devices/views/provisioning_screen.dart';
33
import 'package:borneo_kernel_abstractions/models/supported_device_descriptor.dart';
44
import 'package:flutter/material.dart';
5+
import 'package:borneo_app/core/services/platform_service.dart';
56
import 'package:flutter_gettext/flutter_gettext/context_ext.dart';
67
import 'package:flutter_gettext/flutter_gettext/gettext_localizations.dart';
78
import 'package:provider/provider.dart';
@@ -25,6 +26,7 @@ class DeviceDiscoveryScreen extends StatelessWidget {
2526
cb.read<IDeviceManager>(),
2627
cb.read<IBleProvisioner>(),
2728
cb.read<IDeviceModuleRegistry>(),
29+
cb.read<PlatformService>(), // injected platform helper
2830
globalEventBus: cb.read<EventBus>(),
2931
gt: gt,
3032
logger: cb.read<Logger>(),

client/lib/main.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
1616
import 'package:logger/logger.dart';
1717
import 'package:path_provider/path_provider.dart';
1818
import 'package:provider/provider.dart' as provider;
19+
import 'package:borneo_app/core/services/platform_service.dart';
1920
import 'package:provider/single_child_widget.dart';
2021
import 'package:shared_preferences/shared_preferences.dart';
2122
import 'package:event_bus/event_bus.dart';
@@ -129,6 +130,9 @@ Future<Widget> buildAppWidget({
129130
// LocaleService
130131
provider.Provider<ILocaleService>(create: (_) => AppLocaleService(), lazy: false),
131132

133+
// PlatformService (used by various components to make platform checks testable)
134+
provider.Provider<PlatformService>(create: (_) => PlatformServiceImpl(), lazy: false),
135+
132136
// EventBus
133137
provider.Provider<EventBus>(create: (_) => bus, lazy: false),
134138

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import 'dart:io' show Platform;
2+
import 'package:flutter/foundation.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:borneo_app/core/services/platform_service.dart';
5+
6+
class FakePlatformService implements PlatformService {
7+
@override
8+
bool isAndroid;
9+
10+
@override
11+
bool isDesktop;
12+
13+
@override
14+
bool isIOS;
15+
16+
@override
17+
bool isLinux;
18+
19+
@override
20+
bool isMacOS;
21+
22+
@override
23+
bool isMobile;
24+
25+
@override
26+
bool isWeb;
27+
28+
FakePlatformService({
29+
this.isWeb = false,
30+
this.isAndroid = false,
31+
this.isIOS = false,
32+
this.isWindows = false,
33+
this.isMacOS = false,
34+
this.isLinux = false,
35+
}) : isDesktop = isWindows || isMacOS || isLinux,
36+
isMobile = isAndroid || isIOS;
37+
38+
@override
39+
bool isWindows = false;
40+
}
41+
42+
void main() {
43+
group('PlatformServiceImpl (real)', () {
44+
final service = PlatformServiceImpl();
45+
46+
test('flags reflect current runtime', () {
47+
// can't assert specific values since they vary by host; just ensure
48+
// consistency between helpers and raw checks.
49+
expect(service.isWeb, kIsWeb);
50+
if (!kIsWeb) {
51+
expect(service.isAndroid, Platform.isAndroid);
52+
expect(service.isIOS, Platform.isIOS);
53+
expect(service.isWindows, Platform.isWindows);
54+
expect(service.isMacOS, Platform.isMacOS);
55+
expect(service.isLinux, Platform.isLinux);
56+
}
57+
58+
expect(service.isMobile, service.isAndroid || service.isIOS);
59+
expect(service.isDesktop, service.isWindows || service.isMacOS || service.isLinux);
60+
});
61+
});
62+
63+
group('FakePlatformService', () {
64+
test('allows test-friendly overrides', () {
65+
final fake = FakePlatformService(isWeb: true, isAndroid: false, isIOS: false);
66+
expect(fake.isWeb, true);
67+
expect(fake.isDesktop, false);
68+
expect(fake.isMobile, false);
69+
70+
final mobileFake = FakePlatformService(isAndroid: true);
71+
expect(mobileFake.isMobile, true);
72+
expect(mobileFake.isDesktop, false);
73+
});
74+
});
75+
}

client/test/view_models/device_discovery_view_model_test.dart

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:borneo_app/features/devices/view_models/device_discovery_view_mo
1313
import 'package:borneo_app/core/services/devices/device_manager.dart';
1414
import 'package:borneo_app/core/services/devices/ble_provisioner.dart';
1515
import 'package:borneo_app/core/services/devices/device_module_registry.dart';
16+
import 'package:borneo_app/core/services/platform_service.dart';
1617
import 'package:borneo_app/features/devices/models/device_entity.dart';
1718
import 'package:borneo_app/features/devices/models/device_module_metadata.dart';
1819
import 'package:lw_wot/wot.dart';
@@ -23,6 +24,43 @@ import '../mocks/mocks.dart';
2324

2425
// Minimal implementations / fakes for the interfaces used by the view model.
2526

27+
// A tiny fake that lets tests pretend they are running on a particular
28+
// platform without depending on the real `dart:io` APIs.
29+
class FakePlatformService implements PlatformService {
30+
@override
31+
bool isWeb;
32+
33+
@override
34+
bool isAndroid;
35+
36+
@override
37+
bool isIOS;
38+
39+
@override
40+
bool isWindows;
41+
42+
@override
43+
bool isMacOS;
44+
45+
@override
46+
bool isLinux;
47+
48+
FakePlatformService({
49+
this.isWeb = false,
50+
this.isAndroid = false,
51+
this.isIOS = false,
52+
this.isWindows = false,
53+
this.isMacOS = false,
54+
this.isLinux = false,
55+
});
56+
57+
@override
58+
bool get isMobile => isAndroid || isIOS;
59+
60+
@override
61+
bool get isDesktop => isWindows || isMacOS || isLinux;
62+
}
63+
2664
class FakeDeviceManager implements IDeviceManager {
2765
// ignore: unused_field
2866
final EventBus _bus = EventBus();
@@ -168,66 +206,59 @@ void main() {
168206
late DeviceDiscoveryViewModel vm;
169207
late FakeBleProvisioner bleProv;
170208

171-
setUp(() {
172-
bleProv = FakeBleProvisioner();
173-
vm = DeviceDiscoveryViewModel(
209+
// helper to construct a VM configured for mobile/desktop and an optional
210+
// permission stub.
211+
DeviceDiscoveryViewModel makeVm({
212+
required bool mobile,
213+
Future<bool> Function()? permissions,
214+
FakeBleProvisioner? ble,
215+
}) {
216+
bleProv = ble ?? FakeBleProvisioner();
217+
return DeviceDiscoveryViewModel(
174218
Logger(),
175219
FakeDeviceManager(),
176220
bleProv,
177221
FakeDeviceModuleRegistry(),
222+
FakePlatformService(isAndroid: mobile, isIOS: false, isWindows: !mobile),
178223
globalEventBus: EventBus(),
179224
gt: FakeGettext(),
180225
logger: Logger(),
181-
requestBlePermissions: () async => false,
226+
requestBlePermissions: permissions ?? () async => false,
182227
);
183-
});
228+
}
184229

185230
test('startDiscovery does not call BLE scan when permissions denied', () async {
231+
vm = makeVm(mobile: true, permissions: () async => false);
186232
expect(bleProv.scanCalled, isFalse);
187233
await vm.startDiscovery();
188234
expect(bleProv.scanCalled, isFalse);
189235
expect(vm.scanError.value, 'Bluetooth permissions are required to discover devices.');
190236
});
191237

192238
test('startDiscovery calls BLE scan when permissions granted', () async {
193-
// create new vm with permission true
194-
vm = DeviceDiscoveryViewModel(
195-
Logger(),
196-
FakeDeviceManager(),
197-
bleProv,
198-
FakeDeviceModuleRegistry(),
199-
globalEventBus: EventBus(),
200-
gt: FakeGettext(),
201-
logger: Logger(),
202-
requestBlePermissions: () async => true,
203-
);
204-
239+
vm = makeVm(mobile: true, permissions: () async => true);
205240
await vm.startDiscovery();
206-
// allow _startBleScan unawaited future to run
241+
// scanning happens asynchronously; give it a chance
207242
await Future.delayed(Duration.zero);
208243
expect(bleProv.scanCalled, isTrue);
209244
});
210245

211246
test('startDiscovery handles platform exception permission denial', () async {
212247
final errorProv = FakeBleProvisioner();
213-
// override to throw
214248
errorProv.scanImpl = (String prefix, {CancellationToken? cancelToken}) async {
215249
throw PlatformException(code: 'PERMISSION_DENIED', message: 'nope');
216250
};
217-
vm = DeviceDiscoveryViewModel(
218-
Logger(),
219-
FakeDeviceManager(),
220-
errorProv,
221-
FakeDeviceModuleRegistry(),
222-
globalEventBus: EventBus(),
223-
gt: FakeGettext(),
224-
logger: Logger(),
225-
requestBlePermissions: () async => true,
226-
);
227-
251+
vm = makeVm(mobile: true, permissions: () async => true, ble: errorProv);
228252
await vm.startDiscovery();
229253
await Future.delayed(Duration.zero);
230254
expect(vm.scanError.value, 'Bluetooth permissions are required to discover devices.');
231255
});
256+
257+
test('non‑mobile platforms skip BLE scan entirely', () async {
258+
vm = makeVm(mobile: false, permissions: () async => true);
259+
await vm.startDiscovery();
260+
expect(bleProv.scanCalled, isFalse);
261+
expect(vm.scanError.value, isNull);
262+
});
232263
});
233264
}

0 commit comments

Comments
 (0)