import 'dart:async'; import 'dart:io'; import 'package:drift/native.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/seerr_account_provider.dart'; import 'package:plezy/providers/theme_provider.dart'; import 'package:plezy/providers/trackers_provider.dart'; import 'package:plezy/screens/settings/settings_screen.dart'; import 'package:plezy/services/background_work_diagnostics_service.dart'; import 'package:plezy/services/donation_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/file_picker_service.dart'; import 'package:plezy/services/settings_export_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/update_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/dialog_action_button.dart'; import 'package:plezy/widgets/focusable_list_tile.dart'; import 'package:plezy/widgets/loading_indicator_box.dart'; import 'package:plezy/widgets/setting_tile.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/io_fakes.dart'; import '../../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late PathProviderPlatform originalPathProvider; late _FakeDirectoryPicker directoryPicker; late Directory temporaryDirectory; setUpAll(() { originalPathProvider = PathProviderPlatform.instance; LocaleSettings.setLocaleSync(AppLocale.en); }); setUp(() async { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); DownloadStorageService.resetForTesting(); temporaryDirectory = await Directory.systemTemp.createTemp('plezy_settings_screen_test_'); PathProviderPlatform.instance = FakePathProvider(temporaryDirectory); TvDetectionService.debugSetAppleTVOverride(false); PlatformDetector.debugSetIsDesktopOSOverride(false); directoryPicker = _FakeDirectoryPicker(); FilePickerService.setDelegateForTesting(directoryPicker); await SettingsService.getInstance(); }); tearDown(() async { TvDetectionService.debugSetAppleTVOverride(null); PlatformDetector.debugSetIsDesktopOSOverride(null); DownloadStorageService.resetForTesting(); FilePickerService.setDelegateForTesting(null); SettingsService.resetForTesting(); PathProviderPlatform.instance = originalPathProvider; if (temporaryDirectory.existsSync()) { await temporaryDirectory.delete(recursive: true); } }); testWidgets('system back closes Manage Libraries without popping pushed settings', (tester) async { final harness = await _pumpSettingsScreen(tester, pushSettingsRoute: true); addTearDown(() => harness.dispose(tester)); unawaited( harness.libraries.updateLibraryOrder([ const MediaLibrary( id: 'maestro-movies', backend: MediaBackend.jellyfin, title: 'Maestro Movies', kind: MediaKind.movie, ), ]), ); await _pumpUi(tester); await tester.tap(find.text(t.libraries.manageLibraries)); await _pumpUi(tester); expect(find.text('Maestro Movies'), findsOneWidget); // Android can dispatch one physical Back through both the focused key // path and Navigator.popRoute. The old modal fallback consumed one path // and let the other pop Settings itself. await tester.sendKeyDownEvent(LogicalKeyboardKey.escape); await tester.binding.handlePopRoute(); await tester.pump(); await tester.sendKeyUpEvent(LogicalKeyboardKey.escape); await _pumpUi(tester); expect(find.text('Maestro Movies'), findsNothing, reason: 'system back dismisses the sheet'); expect(find.text(t.settings.services), findsOneWidget, reason: 'the settings route remains current'); expect(find.text('Settings launcher'), findsNothing, reason: 'system back must not pop the settings route'); }); testWidgets('migrated rows retain the shared compact row geometry and activation', (tester) async { final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); final rows = <_MigratedRow>[ _MigratedRow( title: t.settings.supportDeveloper, focusLabel: 'settings_donate', isVisible: DonationService.isEnabled, ), _MigratedRow(title: t.settings.watchTogetherRelay, focusLabel: 'settings_watch_together_relay'), _MigratedRow(title: t.settings.clearImageCache, focusLabel: 'settings_clear_image_cache'), _MigratedRow(title: t.settings.resetSettings, focusLabel: 'settings_reset_settings'), const _MigratedRow(title: 'Test Sentry', isVisible: kDebugMode), const _MigratedRow(title: 'Test ANR', isVisible: kDebugMode), _MigratedRow(title: t.settings.exportSettings, focusLabel: 'settings_export_settings'), _MigratedRow(title: t.settings.importSettings, focusLabel: 'settings_import_settings'), _MigratedRow( title: t.settings.checkForUpdates, focusLabel: 'settings_check_for_updates', isVisible: UpdateService.isUpdateCheckEnabled && UpdateService.useNativeUpdater, hasSubtitle: false, ), ]; final referenceHeight = tester.getSize(_focusableTileFor(t.settings.viewLogs)).height; for (final row in rows) { final navigationTile = _navigationTileFor(row.title); if (!row.isVisible) { expect(navigationTile, findsNothing, reason: '${row.title} must honor its production gate'); continue; } expect(navigationTile, findsOneWidget, reason: '${row.title} must use SettingNavigationTile'); final focusableFinder = _focusableTileWithin(navigationTile); final focusable = tester.widget(focusableFinder); final materialTile = tester.widget( find.descendant(of: focusableFinder, matching: find.byType(ListTile)), ); expect(focusable.dense, isTrue, reason: '${row.title} must use the shared compact row density'); expect(focusable.visualDensity, const VisualDensity(vertical: -3)); expect(materialTile.dense, isTrue); expect(materialTile.visualDensity, const VisualDensity(vertical: -3)); expect(focusable.onTap, isNotNull, reason: '${row.title} must remain pointer activatable'); expect(materialTile.onTap, isNotNull); expect(materialTile.focusNode, isNotNull, reason: '${row.title} must remain D-pad focusable'); expect(materialTile.focusNode!.canRequestFocus, isTrue); final pointerRegions = tester.widgetList( find.descendant(of: focusableFinder, matching: find.byType(MouseRegion)), ); expect(pointerRegions.any((region) => region.cursor == SystemMouseCursors.click), isTrue); if (row.focusLabel != null) { expect(focusable.focusNode?.debugLabel, row.focusLabel); expect(materialTile.focusNode, same(focusable.focusNode)); } else { expect(focusable.focusNode, isNull, reason: '${row.title} intentionally uses the tile-owned focus node'); } if (row.hasSubtitle) { // Subtitle rows occupy the same vertical space as an existing standard // SettingNavigationTile in this screen. expect(tester.getSize(focusableFinder).height, referenceHeight); } } // Exercise both input paths against real callbacks rather than merely // checking that callbacks are non-null. final relayMaterialTile = tester.widget( find.descendant(of: _focusableTileFor(t.settings.watchTogetherRelay), matching: find.byType(ListTile)), ); relayMaterialTile.focusNode!.requestFocus(); await tester.pump(); expect(relayMaterialTile.focusNode!.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.watchTogetherRelay), findsWidgets); Navigator.of(tester.element(find.byType(AlertDialog))).pop(); await _pumpUi(tester); await tester.tap(find.text(t.settings.clearImageCache)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.clearImageCache), findsWidgets); }); testWidgets('special download and generic update rows keep rich content on the shared compact row', (tester) async { final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); if (Platform.isIOS) { expect(find.text(t.settings.downloadLocationDefault), findsNothing); } else { final downloadTileFinder = _focusableTileFor(t.settings.downloadLocationDefault); final materialDownloadTile = tester.widget( find.descendant(of: downloadTileFinder, matching: find.byType(ListTile)), ); final downloadTile = tester.widget(downloadTileFinder); final subtitle = downloadTile.subtitle! as Text; expect(_navigationTileFor(t.settings.downloadLocationDefault), findsNothing); expect(materialDownloadTile.dense, isTrue); expect(materialDownloadTile.visualDensity, const VisualDensity(vertical: -3)); expect(downloadTile.dense, isTrue); expect(downloadTile.visualDensity, const VisualDensity(vertical: -3)); expect(downloadTile.leading, isA()); expect(downloadTile.trailing, isA()); expect(subtitle.maxLines, 2); expect(subtitle.overflow, TextOverflow.ellipsis); expect(downloadTile.onTap, isNotNull); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.downloadLocationDescription), findsOneWidget); Navigator.of(tester.element(find.byType(AlertDialog))).pop(); await _pumpUi(tester); } if (!UpdateService.isUpdateCheckEnabled) { expect(find.text(t.settings.checkForUpdates), findsNothing); return; } if (UpdateService.useNativeUpdater) { expect(_navigationTileFor(t.settings.checkForUpdates), findsOneWidget); return; } final updateTileFinder = _focusableTileFor(t.settings.checkForUpdates); final materialUpdateTile = tester.widget( find.descendant(of: updateTileFinder, matching: find.byType(ListTile)), ); final updateTile = tester.widget(updateTileFinder); expect(_navigationTileFor(t.settings.checkForUpdates), findsNothing); expect(updateTile.dense, isTrue); expect(materialUpdateTile.dense, isTrue); expect(materialUpdateTile.visualDensity, const VisualDensity(vertical: -3)); expect(updateTile.visualDensity, const VisualDensity(vertical: -3)); expect(updateTile.trailing, isA()); expect(updateTile.onTap, isNotNull); expect(find.descendant(of: updateTileFinder, matching: find.byType(LoadingIndicatorBox)), findsNothing); // The generic updater deliberately remains a FocusableListTile: unlike a // SettingNavigationTile, its trailing slot can be replaced by the progress // indicator and its callback disabled while a request is in flight. expect(materialUpdateTile.focusNode, isNotNull); }); testWidgets('background downloads tile renders and opens the injected blocked status', (tester) async { const channel = MethodChannel('com.plezy/device.settings-background-test'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { return switch (call.method) { 'getBackgroundWorkSignals' => { 'verdict': 'blocked', 'reasons': ['background_restricted'], }, 'openBackgroundSettings' => true, _ => null, }; }); final diagnostics = BackgroundWorkDiagnosticsService.forTesting(channel: channel); await diagnostics.refresh(); final harness = await _pumpSettingsScreen(tester, backgroundWorkDiagnosticsService: diagnostics); addTearDown(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); await harness.dispose(tester); diagnostics.dispose(); }); expect(find.text(t.downloads.backgroundWarning.statusTile), findsOneWidget); expect(find.text(t.downloads.backgroundWarning.statusBlocked), findsOneWidget); await tester.tap(find.text(t.downloads.backgroundWarning.statusTile)); await _pumpUi(tester); expect(find.text(t.downloads.backgroundWarning.sheetTitle), findsOneWidget); }); testWidgets('unprobed background diagnostics stay unknown instead of claiming success', (tester) async { final diagnostics = BackgroundWorkDiagnosticsService.forTesting(); final harness = await _pumpSettingsScreen(tester, backgroundWorkDiagnosticsService: diagnostics); addTearDown(() async { await harness.dispose(tester); diagnostics.dispose(); }); expect(find.text(t.downloads.backgroundWarning.statusUnknown), findsOneWidget); expect(find.text(t.downloads.backgroundWarning.statusOk), findsNothing); }); testWidgets('background downloads tile is absent when diagnostics are unsupported', (tester) async { final diagnostics = BackgroundWorkDiagnosticsService.forTesting(supported: false); final harness = await _pumpSettingsScreen(tester, backgroundWorkDiagnosticsService: diagnostics); addTearDown(() async { await harness.dispose(tester); diagnostics.dispose(); }); expect(find.text(t.downloads.backgroundWarning.statusTile), findsNothing); }); testWidgets('relay dialog rejects invalid bases without persisting them', (tester) async { final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.watchTogetherRelay)); await _pumpUi(tester); await tester.enterText(find.byType(TextField), 'ws://opaque-user@relay.example.test/path?wrong=route'); await tester.tap(find.text(t.common.save)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.watchTogetherRelayInvalid), findsOneWidget); expect(SettingsService.instance.read(SettingsService.customRelayUrl), isNull); }); testWidgets('relay dialog saves and reopens the canonical base', (tester) async { final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.watchTogetherRelay)); await _pumpUi(tester); await tester.enterText(find.byType(TextField), ' HTTP://Relay.Example.Test:8080/prefix/// '); await tester.tap(find.text(t.common.save)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsNothing); expect(SettingsService.instance.read(SettingsService.customRelayUrl), 'http://relay.example.test:8080/prefix'); await tester.tap(find.text(t.settings.watchTogetherRelay)); await _pumpUi(tester); expect(find.widgetWithText(TextField, 'http://relay.example.test:8080/prefix'), findsOneWidget); }); testWidgets('folder replacement uses the provider coordinator', (tester) async { final selectedDirectory = Directory('${temporaryDirectory.path}/selected-downloads'); directoryPicker.directoryPath = selectedDirectory.path; final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); await tester.tap(find.text(t.settings.selectFolder)); await _pumpUi(tester); expect(harness.locationEvents, ['path:${selectedDirectory.path}', 'type:file', 'refresh']); expect(SettingsService.instance.read(SettingsService.customDownloadPath), selectedDirectory.path); }); testWidgets('download location reset uses the provider coordinator', (tester) async { await SettingsService.instance.write( SettingsService.customDownloadPath, '${temporaryDirectory.path}/old-downloads', ); await SettingsService.instance.write(SettingsService.customDownloadPathType, 'file'); final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.downloadLocationCustom)); await _pumpUi(tester); await tester.tap(find.text(t.settings.resetToDefault)); await _pumpUi(tester); expect(harness.locationEvents, ['path:null', 'type:null', 'refresh']); expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull); }); testWidgets('Reset All resets download location through the provider first', (tester) async { await SettingsService.instance.write( SettingsService.customDownloadPath, '${temporaryDirectory.path}/old-downloads', ); await SettingsService.instance.write(SettingsService.customDownloadPathType, 'file'); final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.resetSettings)); await _pumpUi(tester); await tester.tap(find.text(t.common.reset)); await _pumpUi(tester); expect(harness.locationEvents.take(3), ['path:null', 'type:null', 'refresh']); expect(SettingsService.instance.read(SettingsService.customDownloadPath), isNull); expect(find.text(t.settings.resetSettingsSuccess), findsOneWidget); }); testWidgets('cancelled directory picker remains silent and leaves the dialog retryable', (tester) async { final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); await tester.tap(find.text(t.settings.selectFolder)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.saveFailed), findsNothing); expect(find.text(t.settings.downloadLocationChanged), findsNothing); expect(harness.locationEvents, isEmpty); }); testWidgets('directory picker platform failure uses shared settings feedback', (tester) async { directoryPicker.directoryError = PlatformException(code: 'picker_failed'); final harness = await _pumpSettingsScreen(tester); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); await tester.tap(find.text(t.settings.selectFolder)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.saveFailed), findsOneWidget); expect(harness.locationEvents, isEmpty); }); testWidgets('directory filesystem failure uses shared settings feedback', (tester) async { directoryPicker.directoryPath = '${temporaryDirectory.path}/selected-downloads'; final harness = await _pumpSettingsScreen( tester, writableChecker: (_) async => throw const FileSystemException('writable check failed'), ); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); await tester.tap(find.text(t.settings.selectFolder)); await _pumpUi(tester); expect(find.byType(AlertDialog), findsOneWidget); expect(find.text(t.settings.saveFailed), findsOneWidget); expect(harness.locationEvents, isEmpty); }); testWidgets('late directory picker completion does not use a disposed context', (tester) async { directoryPicker.directoryGate = Completer(); final harness = await _pumpSettingsScreen(tester); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); await tester.tap(find.text(t.settings.selectFolder)); await tester.pump(); await harness.dispose(tester); directoryPicker.directoryGate!.complete('${temporaryDirectory.path}/late-downloads'); await tester.pump(); expect(tester.takeException(), isNull); expect(harness.locationEvents, isEmpty); }); testWidgets('late directory picker failure does not use a disposed context', (tester) async { directoryPicker.directoryGate = Completer(); final harness = await _pumpSettingsScreen(tester); await tester.tap(find.text(t.settings.downloadLocationDefault)); await _pumpUi(tester); await tester.tap(find.text(t.settings.selectFolder)); await tester.pump(); await harness.dispose(tester); directoryPicker.directoryGate!.completeError(PlatformException(code: 'late_picker_failure')); await tester.pump(); expect(tester.takeException(), isNull); expect(harness.locationEvents, isEmpty); }); testWidgets('late settings export failure does not use a disposed context', (tester) async { final exportGate = Completer(); final harness = await _pumpSettingsScreen(tester, settingsExporter: () => exportGate.future); await tester.tap(find.text(t.settings.exportSettings)); await tester.pump(); await harness.dispose(tester); exportGate.completeError(PlatformException(code: 'late_export_failure')); await tester.pump(); expect(tester.takeException(), isNull); }); testWidgets('late settings import failure does not use a disposed context', (tester) async { final importGate = Completer(); final harness = await _pumpSettingsScreen(tester, settingsImporter: () => importGate.future); await tester.tap(find.text(t.settings.importSettings)); await _pumpUi(tester); await tester.tap(find.widgetWithText(DialogActionButton, t.settings.importSettings)); await tester.pump(); await harness.dispose(tester); importGate.completeError(PlatformException(code: 'late_import_failure')); await tester.pump(); expect(tester.takeException(), isNull); }); testWidgets('settings export platform failure uses shared settings feedback', (tester) async { final harness = await _pumpSettingsScreen( tester, settingsExporter: () async => throw PlatformException(code: 'save_failed'), ); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.exportSettings)); await _pumpUi(tester); expect(find.text(t.settings.saveFailed), findsOneWidget); expect(find.text(t.settings.exportSettingsSuccess), findsNothing); }); testWidgets('settings import platform failure uses shared settings feedback', (tester) async { final harness = await _pumpSettingsScreen( tester, settingsImporter: () async => throw PlatformException(code: 'pick_failed'), ); addTearDown(() => harness.dispose(tester)); await tester.tap(find.text(t.settings.importSettings)); await _pumpUi(tester); await tester.tap(find.widgetWithText(DialogActionButton, t.settings.importSettings)); await _pumpUi(tester); expect(find.text(t.settings.saveFailed), findsOneWidget); expect(find.text(t.settings.importSettingsSuccess), findsNothing); }); } Finder _navigationTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(SettingNavigationTile)); Finder _focusableTileFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(FocusableListTile)); Future _pumpUi(WidgetTester tester) async { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); } Finder _focusableTileWithin(Finder navigationTile) => find.descendant(of: navigationTile, matching: find.byType(FocusableListTile)); class _MigratedRow { const _MigratedRow({required this.title, this.focusLabel, this.isVisible = true, this.hasSubtitle = true}); final String title; final String? focusLabel; final bool isVisible; final bool hasSubtitle; } class _SettingsHarness { _SettingsHarness({ required this.database, required this.plexHome, required this.activeProfile, required this.libraries, required this.hiddenLibraries, required this.theme, required this.trackers, required this.trackerHttpClients, required this.seerr, required this.downloadManager, required this.downloadProvider, required this.locationEvents, }); final AppDatabase database; final PlexHomeService plexHome; final ActiveProfileProvider activeProfile; final LibrariesProvider libraries; final HiddenLibrariesProvider hiddenLibraries; final ThemeProvider theme; final TrackersProvider trackers; final List trackerHttpClients; final SeerrAccountProvider seerr; final DownloadManagerService downloadManager; final DownloadProvider downloadProvider; final List locationEvents; Future dispose(WidgetTester tester) async { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); downloadProvider.dispose(); downloadManager.dispose(); hiddenLibraries.dispose(); libraries.dispose(); theme.dispose(); trackers.dispose(); seerr.dispose(); activeProfile.dispose(); await plexHome.dispose(); await database.close(); expect(trackerHttpClients, hasLength(5)); expect(trackerHttpClients.toSet(), hasLength(5)); for (final client in trackerHttpClients) { expect(client.closeCount, 1); } } } Future<_SettingsHarness> _pumpSettingsScreen( WidgetTester tester, { Future Function(Directory directory)? writableChecker, Future Function()? settingsExporter, Future Function()? settingsImporter, BackgroundWorkDiagnosticsService? backgroundWorkDiagnosticsService, bool pushSettingsRoute = false, }) async { tester.view.physicalSize = const Size(1800, 3200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final database = AppDatabase.forTesting(NativeDatabase.memory()); final connections = ConnectionRegistry(database); final profileConnections = ProfileConnectionRegistry(database); final profiles = ProfileRegistry(database); final plexHome = PlexHomeService( connections: connections, profileConnections: profileConnections, plexHomeUserFetcher: (_) async => const [], ); final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections); final libraries = LibrariesProvider(); final hiddenLibraries = HiddenLibrariesProvider(storageService: _FakeHiddenLibrariesStorage()); await hiddenLibraries.ensureInitialized(); final theme = ThemeProvider(); final trackerHttpClients = []; FakeHttpClient trackerHttpClientFactory() { final client = FakeHttpClient(HttpStatus.ok, const []); trackerHttpClients.add(client); return client; } final trackers = TrackersProvider(httpClientFactory: trackerHttpClientFactory); final seerr = SeerrAccountProvider(); final settingsService = SettingsService.instance; final storageService = DownloadStorageService.instance; await tester.runAsync(() => storageService.initialize(settingsService)); final locationEvents = []; final downloadManager = DownloadManagerService( database: database, storageService: storageService, clientResolver: (_, {clientScopeId}) => null, downloadsSupportedOverride: false, downloadLocationReader: () => ( path: settingsService.read(SettingsService.customDownloadPath), type: settingsService.read(SettingsService.customDownloadPathType), ), downloadPathWriter: (value) async { locationEvents.add('path:$value'); await settingsService.write(SettingsService.customDownloadPath, value); }, downloadPathTypeWriter: (value) async { locationEvents.add('type:$value'); await settingsService.write(SettingsService.customDownloadPathType, value); }, downloadStorageRefresher: () async { locationEvents.add('refresh'); }, )..recoveryFuture = Future.value(); final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: database); final harness = _SettingsHarness( database: database, plexHome: plexHome, activeProfile: activeProfile, libraries: libraries, hiddenLibraries: hiddenLibraries, theme: theme, trackers: trackers, trackerHttpClients: trackerHttpClients, seerr: seerr, downloadManager: downloadManager, downloadProvider: downloadProvider, locationEvents: locationEvents, ); await tester.pumpWidget( TranslationProvider( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: activeProfile), ChangeNotifierProvider.value(value: libraries), ChangeNotifierProvider.value(value: hiddenLibraries), ChangeNotifierProvider.value(value: theme), ChangeNotifierProvider.value(value: trackers), ChangeNotifierProvider.value(value: seerr), ChangeNotifierProvider.value(value: downloadProvider), ], child: MaterialApp( theme: monoTheme(dark: true).copyWith(platform: TargetPlatform.android), home: pushSettingsRoute ? Builder( builder: (context) => Scaffold( body: Center( child: ElevatedButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => SettingsScreen( downloadDirectoryWritableChecker: writableChecker ?? (_) async => true, settingsExporter: settingsExporter, settingsImporter: settingsImporter, backgroundWorkDiagnosticsService: backgroundWorkDiagnosticsService, ), ), ), child: const Text('Settings launcher'), ), ), ), ) : SettingsScreen( downloadDirectoryWritableChecker: writableChecker ?? (_) async => true, settingsExporter: settingsExporter, settingsImporter: settingsImporter, backgroundWorkDiagnosticsService: backgroundWorkDiagnosticsService, ), ), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); if (pushSettingsRoute) { await tester.tap(find.text('Settings launcher')); await _pumpUi(tester); } return harness; } class _FakeHiddenLibrariesStorage implements StorageService { @override Set getHiddenLibraries() => {}; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } class _FakeDirectoryPicker implements FilePickerDelegate { @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); String? directoryPath; Object? directoryError; Completer? directoryGate; @override Future getDirectoryPath({ String? dialogTitle, String? initialDirectory, bool lockParentWindow = false, }) async { final gate = directoryGate; if (gate != null) return gate.future; final error = directoryError; if (error != null) throw error; return directoryPath; } }