Files
plezy/test/screens/settings/settings_screen_test.dart
T
edde746 c48cbf7059 fix(settings): dismiss Manage Libraries without leaving Settings
On phone layouts main_screen pushes SettingsScreen as its own route, and
that route carried no OverlaySheetHost. showAdaptive could not find one
from the tile's context, so Manage Libraries fell back to
showModalBottomSheet. The sheet also owns a focused Back handler, so a
single Android Back arrived twice — once as a key event, once as
popRoute — and the two route-based paths raced, tearing down Settings
along with the sheet.

Installs one route-local host when no enclosing host exists, and opens
the sheet from a context below it. OverlaySheetHost then holds the route
while a sheet is open and deduplicates the key path, so one Back closes
only the sheet.
2026-07-26 23:05:53 +02:00

789 lines
32 KiB
Dart

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/providers/trakt_account_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<FocusableListTile>(focusableFinder);
final materialTile = tester.widget<ListTile>(
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<MouseRegion>(
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<ListTile>(
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<ListTile>(
find.descendant(of: downloadTileFinder, matching: find.byType(ListTile)),
);
final downloadTile = tester.widget<FocusableListTile>(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<AppIcon>());
expect(downloadTile.trailing, isA<AppIcon>());
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<ListTile>(
find.descendant(of: updateTileFinder, matching: find.byType(ListTile)),
);
final updateTile = tester.widget<FocusableListTile>(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<AppIcon>());
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<String?>();
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<String?>();
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<String?>();
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<ImportResult?>();
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<void> _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.trakt,
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 TraktAccountProvider trakt;
final TrackersProvider trackers;
final List<FakeHttpClient> trackerHttpClients;
final SeerrAccountProvider seerr;
final DownloadManagerService downloadManager;
final DownloadProvider downloadProvider;
final List<String> locationEvents;
Future<void> dispose(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
downloadProvider.dispose();
downloadManager.dispose();
hiddenLibraries.dispose();
libraries.dispose();
theme.dispose();
trakt.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<bool> Function(Directory directory)? writableChecker,
Future<String?> Function()? settingsExporter,
Future<ImportResult?> 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>[];
FakeHttpClient trackerHttpClientFactory() {
final client = FakeHttpClient(HttpStatus.ok, const <int>[]);
trackerHttpClients.add(client);
return client;
}
final trakt = TraktAccountProvider(httpClientFactory: trackerHttpClientFactory);
final trackers = TrackersProvider(httpClientFactory: trackerHttpClientFactory);
final seerr = SeerrAccountProvider();
final settingsService = SettingsService.instance;
final storageService = DownloadStorageService.instance;
await tester.runAsync(() => storageService.initialize(settingsService));
final locationEvents = <String>[];
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<void>.value();
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: database);
final harness = _SettingsHarness(
database: database,
plexHome: plexHome,
activeProfile: activeProfile,
libraries: libraries,
hiddenLibraries: hiddenLibraries,
theme: theme,
trakt: trakt,
trackers: trackers,
trackerHttpClients: trackerHttpClients,
seerr: seerr,
downloadManager: downloadManager,
downloadProvider: downloadProvider,
locationEvents: locationEvents,
);
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfile),
ChangeNotifierProvider<LibrariesProvider>.value(value: libraries),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibraries),
ChangeNotifierProvider<ThemeProvider>.value(value: theme),
ChangeNotifierProvider<TraktAccountProvider>.value(value: trakt),
ChangeNotifierProvider<TrackersProvider>.value(value: trackers),
ChangeNotifierProvider<SeerrAccountProvider>.value(value: seerr),
ChangeNotifierProvider<DownloadProvider>.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<void>(
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<String> 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<String?>? directoryGate;
@override
Future<String?> 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;
}
}