From 61344f7862e34d12103e591ed3bf0fa641f9b5b6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:00:07 +0200 Subject: [PATCH] test: extract shared fixtures and scaffolds Collapse duplicated setup across the suite into six shared helpers under test/test_helpers/ and rewrite the 28 suites that were open-coding it: http_fixtures.dart jsonResponse() for http.Response JSON stubs library_tab_scaffold.dart pumps library tabs under their required ancestors multi_server_fixtures.dart MultiServerProvider wiring for widget tests playback_report_fakes.dart PlaybackReportCall + fake report sinks profile_stack.dart production-shaped profile dependency graph theme.dart testMonoTokens for fast-settling widget tests Net -1245 lines with no change in coverage or assertions. --- test/profiles/active_profile_binder_test.dart | 22 +- .../companion_remote_provider_test.dart | 318 +-- .../providers/user_profile_provider_test.dart | 197 +- .../libraries/library_browse_music_test.dart | 60 +- .../libraries/library_browse_tab_test.dart | 72 +- .../library_collections_tab_test.dart | 47 +- .../libraries/library_playlists_tab_test.dart | 49 +- test/screens/metadata_edit_screen_test.dart | 8 +- test/screens/search_screen_test.dart | 4 +- .../catalog/plex_catalog_source_test.dart | 35 +- .../catalog/seerr_catalog_source_test.dart | 25 +- .../external_player_service_test.dart | 43 +- test/services/jellyfin_client_urls_test.dart | 2192 +++++++---------- test/services/live_session_tracker_test.dart | 58 +- .../music/music_playback_service_test.dart | 52 +- .../offline_watch_sync_service_test.dart | 51 +- .../playback_progress_tracker_test.dart | 111 +- .../playback_report_session_test.dart | 72 +- test/test_helpers/http_fixtures.dart | 7 + test/test_helpers/library_tab_scaffold.dart | 62 + test/test_helpers/multi_server_fixtures.dart | 30 + test/test_helpers/playback_report_fakes.dart | 120 + test/test_helpers/profile_stack.dart | 74 + test/test_helpers/theme.dart | 46 + test/utils/provider_extensions_test.dart | 32 +- test/widgets/chapter_sheet_test.dart | 22 +- test/widgets/media_context_menu_test.dart | 101 +- test/widgets/music/mini_player_test.dart | 27 +- test/widgets/player_queue_spoilers_test.dart | 23 +- .../server_activities_button_test.dart | 19 +- test/widgets/side_navigation_rail_test.dart | 37 +- test/widgets/track_sheet_test.dart | 24 +- test/widgets/video_controls_test.dart | 39 +- test/widgets/video_settings_sheet_test.dart | 25 +- 34 files changed, 1599 insertions(+), 2505 deletions(-) create mode 100644 test/test_helpers/http_fixtures.dart create mode 100644 test/test_helpers/library_tab_scaffold.dart create mode 100644 test/test_helpers/multi_server_fixtures.dart create mode 100644 test/test_helpers/playback_report_fakes.dart create mode 100644 test/test_helpers/profile_stack.dart create mode 100644 test/test_helpers/theme.dart diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 89261df7..aec5ebc6 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -18,13 +18,13 @@ import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; /// Poll [condition] until it holds, failing after [timeout]. Used to observe @@ -74,7 +74,7 @@ void main() { storage: storage, ); manager = MultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); shouldDeferInitialBind = false; binder = ActiveProfileBinder( activeProfile: activeProfile, @@ -193,7 +193,7 @@ void main() { final failingManager = _FailingPlexMultiServerManager(); manager = failingManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -247,7 +247,7 @@ void main() { final mixedManager = _BlockingMixedMultiServerManager(); manager = mixedManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -349,7 +349,7 @@ void main() { final capturingManager = _CapturingMultiServerManager(); manager = capturingManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -591,7 +591,7 @@ void main() { final recoveringManager = _RecordingPlexManager(); manager = recoveringManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -644,7 +644,7 @@ void main() { multiServerProvider.dispose(); manager = testManager ?? _CapturingMultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -809,7 +809,7 @@ void main() { final gated = _GatedJellyfinManager(); manager = gated; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -849,7 +849,7 @@ void main() { final gated = _GatedJellyfinManager(); manager = gated; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -890,7 +890,7 @@ void main() { final failing = _CountingFailingJellyfinManager(); manager = failing; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -929,7 +929,7 @@ void main() { var pinPrompts = 0; manager = _CapturingMultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, diff --git a/test/providers/companion_remote_provider_test.dart b/test/providers/companion_remote_provider_test.dart index 44db7c3a..72b63cf8 100644 --- a/test/providers/companion_remote_provider_test.dart +++ b/test/providers/companion_remote_provider_test.dart @@ -1,29 +1,22 @@ import 'dart:async'; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.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/models/plex/plex_home.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/companion_remote/remote_command.dart'; import 'package:plezy/models/companion_remote/remote_session.dart'; -import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart'; import 'package:plezy/services/companion_remote/lan_discovery_service.dart'; import 'package:plezy/services/companion_remote/remote_auth_context.dart'; import 'package:plezy/services/companion_remote/remote_auth_service.dart'; -import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -473,69 +466,48 @@ void main() { }); test('ensureCryptoReady rebuilds when the active profile/account changes', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profileA = _localProfile('profile-a'); final profileB = _localProfile('profile-b'); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profiles.upsert(profileA); - await profiles.upsert(profileB); - await profileConnections.upsert( + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profiles.upsert(profileA); + await stack.profiles.upsert(profileB); + await stack.profileConnections.upsert( ProfileConnection(profileId: profileA.id, connectionId: accountA.id, userIdentifier: 'admin-a'), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profileB.id, connectionId: accountB.id, userIdentifier: 'admin-b'), makeDefault: true, ); - await storage.setActiveProfileId(profileA.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profileA.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final okA = await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: accountA, ); expect(okA, isTrue); expect(provider.debugCryptoConnectionId, accountA.id); expect(provider.debugCryptoProfileId, profileA.id); - await active.activate(profileB); + await stack.active.activate(profileB); final ok = await provider.ensureCryptoReady( _home('admin-b'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: accountB, ); @@ -545,50 +517,29 @@ void main() { }); test('ensureCryptoReady uses the active local profile Plex row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profile = _localProfile('profile-local'); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: accountB.id, userIdentifier: 'child-b', isDefault: true), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _homeWithUsers('admin-b', ['child-b']), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isTrue); @@ -598,48 +549,27 @@ void main() { }); test('ensureCryptoReady uses the active local profile Jellyfin row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-jf'); - await connections.upsert(jellyfin); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(jellyfin); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( null, - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isTrue); @@ -649,54 +579,33 @@ void main() { }); test('ensureCryptoReady includes every active local profile remote identity', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final account = _plexAccount('plex-a', 'client-a'); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-mixed'); final home = _homeWithUsers('admin-a', ['child-a']); - await connections.upsert(account); - await connections.upsert(jellyfin); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.connections.upsert(jellyfin); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'child-a'), ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( home, - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, plexHomeForConnection: (_) async => home, ); @@ -706,41 +615,20 @@ void main() { }); test('ensureCryptoReady does not fall back to an account without an active profile', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); - await connections.upsert(_plexAccount('plex-a', 'client-a')); - await profiles.upsert(_localProfile('profile-a')); - await active.initialize(); + await stack.connections.upsert(_plexAccount('plex-a', 'client-a')); + await stack.profiles.upsert(_localProfile('profile-a')); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isFalse); @@ -748,48 +636,27 @@ void main() { }); test('resetForLogout clears crypto context', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final account = _plexAccount('plex-a', 'client-a'); final profile = _localProfile('profile-a'); - await connections.upsert(account); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'admin-a'), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: account, ); expect(provider.isCryptoReady, isTrue); @@ -1061,46 +928,28 @@ class _FakeLanDiscoveryService extends LanDiscoveryService { } class _RemoteHarness { - _RemoteHarness({required this.provider, required this.database, required this.activeProfile, required this.plexHome}); + _RemoteHarness({required this.provider, required this.stack}); final CompanionRemoteProvider provider; - final AppDatabase database; - final ActiveProfileProvider activeProfile; - final PlexHomeService plexHome; + final ProfileStack stack; bool _closed = false; static Future<_RemoteHarness> create( CompanionRemotePeerServiceFactory peerServiceFactory, { LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new, }) async { - final database = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(database); - final profileConnections = ProfileConnectionRegistry(database); - final profiles = ProfileRegistry(database); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfile = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final account = _plexAccount('remote-account', 'remote-client'); final profile = _localProfile('remote-profile'); - await connections.upsert(account); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'remote-admin'), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await activeProfile.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider.forTesting( peerServiceFactory: peerServiceFactory, @@ -1108,25 +957,22 @@ class _RemoteHarness { ); final ready = await provider.ensureCryptoReady( _home('remote-admin'), - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: account, ); if (!ready) { throw StateError('Remote test harness failed to initialize crypto'); } - return _RemoteHarness(provider: provider, database: database, activeProfile: activeProfile, plexHome: plexHome); + return _RemoteHarness(provider: provider, stack: stack); } Future close() async { if (_closed) return; _closed = true; if (!provider.isDisposed) provider.dispose(); - await activeProfile.resetForTesting(); - activeProfile.dispose(); - await plexHome.dispose(); - await database.close(); + await stack.dispose(); } } diff --git a/test/providers/user_profile_provider_test.dart b/test/providers/user_profile_provider_test.dart index a3da4a3c..6bea338a 100644 --- a/test/providers/user_profile_provider_test.dart +++ b/test/providers/user_profile_provider_test.dart @@ -1,26 +1,19 @@ import 'dart:convert'; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; -import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/user_profile_provider.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; -import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { setUp(resetSharedPreferencesForTest); @@ -57,30 +50,11 @@ void main() { }); test('settings connection follows the profile default row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); @@ -102,10 +76,10 @@ void main() { deviceId: 'device-a', createdAt: DateTime(2026, 1, 1), ); - await profiles.upsert(profile); - await connections.upsert(plex); - await connections.upsert(jellyfin); - await profileConnections.upsert( + await stack.profiles.upsert(profile); + await stack.connections.upsert(plex); + await stack.connections.upsert(jellyfin); + await stack.profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: plex.id, @@ -115,53 +89,36 @@ void main() { ), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final p = UserProfileProvider() ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); - await profileConnections.setDefault(profile.id, jellyfin.id); + await stack.profileConnections.setDefault(profile.id, jellyfin.id); expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); }); test('watches Plex Home profile connection rows', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, + final stack = await ProfileStack.create( + homeUsers: [_homeUser(uuid: 'home-user-a', title: 'Home User')], ); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final account = PlexAccountConnection( @@ -171,21 +128,23 @@ void main() { accountLabel: 'Plex A', createdAt: DateTime(2026, 1, 1), ); - await connections.upsert(account); - await plexHome.refresh(account); - await storage.setActiveProfileId(plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a')); - await active.initialize(); + await stack.connections.upsert(account); + await stack.plexHome.refresh(account); + await stack.storage.setActiveProfileId( + plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'), + ); + await stack.active.initialize(); final p = UserProfileProvider() ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); - expect(p.debugWatchedProfileConnectionProfileId, active.activeId); + expect(p.debugWatchedProfileConnectionProfileId, stack.active.activeId); }); test('Plex Home profile without a switched token makes no user request', () async { @@ -231,30 +190,11 @@ void main() { }); test('Plex token fallback uses the selected local profile account', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); @@ -272,10 +212,10 @@ void main() { accountLabel: 'Plex B', createdAt: DateTime(2026, 1, 1), ); - await profiles.upsert(profile); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profileConnections.upsert( + await stack.profiles.upsert(profile); + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: accountB.id, @@ -284,8 +224,8 @@ void main() { ), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final requests = []; final auth = _recordingAuth(requests, audioLanguage: 'fra'); @@ -293,9 +233,9 @@ void main() { final p = UserProfileProvider(authService: auth) ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); @@ -356,39 +296,16 @@ PlexAuthService _recordingAuth(List requests, {required String aud } class _HomeProfileFixture { - _HomeProfileFixture({ - required this.db, - required this.active, - required this.plexHome, - required this.auth, - required this.provider, - required this.requests, - }); + _HomeProfileFixture({required this.stack, required this.auth, required this.provider, required this.requests}); - final AppDatabase db; - final ActiveProfileProvider active; - final PlexHomeService plexHome; + final ProfileStack stack; final PlexAuthService auth; final UserProfileProvider provider; final List requests; static Future<_HomeProfileFixture> create({String? switchedToken}) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, + final stack = await ProfileStack.create( + homeUsers: [_homeUser(uuid: 'home-user-a', title: 'Home User')], ); final account = PlexAccountConnection( id: 'plex-parent', @@ -397,12 +314,12 @@ class _HomeProfileFixture { accountLabel: 'Plex Parent', createdAt: DateTime(2026, 1, 1), ); - await connections.upsert(account); - await plexHome.refresh(account); + await stack.connections.upsert(account); + await stack.plexHome.refresh(account); final activeId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'); if (switchedToken != null) { - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection( profileId: activeId, connectionId: account.id, @@ -413,29 +330,23 @@ class _HomeProfileFixture { makeDefault: true, ); } - await storage.setActiveProfileId(activeId); - await active.initialize(); + await stack.storage.setActiveProfileId(activeId); + await stack.active.initialize(); final requests = []; final auth = _recordingAuth(requests, audioLanguage: 'jpn'); final provider = UserProfileProvider(authService: auth) - ..attach(connections: connections, activeProfile: active, profileConnections: profileConnections); - return _HomeProfileFixture( - db: db, - active: active, - plexHome: plexHome, - auth: auth, - provider: provider, - requests: requests, - ); + ..attach( + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, + ); + return _HomeProfileFixture(stack: stack, auth: auth, provider: provider, requests: requests); } Future dispose() async { provider.dispose(); auth.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); } } diff --git a/test/screens/libraries/library_browse_music_test.dart b/test/screens/libraries/library_browse_music_test.dart index 9a4f61b6..8ce74156 100644 --- a/test/screens/libraries/library_browse_music_test.dart +++ b/test/screens/libraries/library_browse_music_test.dart @@ -6,12 +6,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/focus/focusable_button.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.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/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; @@ -20,13 +18,12 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_filter_chip.dart'; import 'package:plezy/widgets/media_card.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/prefs.dart'; final _musicLibrary = MediaLibrary( @@ -88,7 +85,7 @@ void main() { expect(retry.focusNode!.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(harness.browseRequestCount, 2); expect(find.byType(ErrorStateWidget), findsNothing); @@ -115,52 +112,17 @@ void main() { } Future _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness, {MediaLibrary? library}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryBrowseTab( - library: library ?? _musicLibrary, - canGroupByFolders: true, - suppressAutoFocus: true, - onBack: () {}, - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: LibraryBrowseTab( + library: library ?? _musicLibrary, + canGroupByFolders: true, + suppressAutoFocus: true, + onBack: () {}, ), ); - await _pumpRequestFrames(tester); -} - -Future _pumpRequestFrames(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 500)); + await pumpRequestFrames(tester); } class _MusicBrowseHarness { diff --git a/test/screens/libraries/library_browse_tab_test.dart b/test/screens/libraries/library_browse_tab_test.dart index 6b3c2586..909a1dd2 100644 --- a/test/screens/libraries/library_browse_tab_test.dart +++ b/test/screens/libraries/library_browse_tab_test.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/library_filter_result.dart'; import 'package:plezy/media/library_query.dart'; @@ -15,7 +14,6 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_sort.dart'; import 'package:plezy/media/server_capabilities.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; @@ -23,12 +21,11 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_filter_chip.dart'; -import 'package:provider/provider.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/media_items.dart'; import '../../test_helpers/prefs.dart'; @@ -52,12 +49,12 @@ void main() { expect(harness.clientA.pageRequestCount, 0); harness.selectedLibrary.value = harness.libraryB; - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library B'), findsOneWidget); expect(harness.loadedLibraries, [harness.libraryB.globalKey]); sortA.complete(const []); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library A'), findsNothing); expect(find.text('Library B'), findsOneWidget); @@ -79,7 +76,7 @@ void main() { expect(harness.loadedLibraries, isEmpty); harness.selectedLibrary.value = harness.libraryB; - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library A'), findsNothing); expect(find.text('Library B'), findsOneWidget); @@ -125,7 +122,7 @@ void main() { await _pumpUntil(tester, () => clientA.pageRequestCount == 2); emptyPage.complete(const LibraryPage(items: [], totalCount: 0)); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.byType(ErrorStateWidget), findsNothing); expect(find.byType(EmptyStateWidget), findsOneWidget); @@ -135,56 +132,21 @@ void main() { } Future _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: ValueListenableBuilder( - valueListenable: harness.selectedLibrary, - builder: (context, library, _) => LibraryBrowseTab( - library: library, - canGroupByFolders: true, - isActive: true, - onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), - onBack: () => harness.chromeFocusRequests++, - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: ValueListenableBuilder( + valueListenable: harness.selectedLibrary, + builder: (context, library, _) => LibraryBrowseTab( + library: library, + canGroupByFolders: true, + isActive: true, + onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), + onBack: () => harness.chromeFocusRequests++, ), ), ); - if (settle) await _pumpRequestFrames(tester); -} - -Future _pumpRequestFrames(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 500)); + if (settle) await pumpRequestFrames(tester); } Future _pumpUntil(WidgetTester tester, bool Function() condition) async { diff --git a/test/screens/libraries/library_collections_tab_test.dart b/test/screens/libraries/library_collections_tab_test.dart index 09eaf6a9..72f37f46 100644 --- a/test/screens/libraries/library_collections_tab_test.dart +++ b/test/screens/libraries/library_collections_tab_test.dart @@ -6,7 +6,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -14,23 +13,21 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/plex/plex_config.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_collections_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('collection-server'); @@ -93,39 +90,11 @@ void main() { } Future _pumpTab(WidgetTester tester, {required _CollectionHarness harness, required MediaLibrary library}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), + size: const Size(800, 600), ); await tester.pumpAndSettle(); } @@ -137,7 +106,7 @@ class _CollectionHarness { _CollectionHarness._({required this.database, required MediaServerClient client}) { manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } factory _CollectionHarness.plex() { diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 8ba1aa5d..6b15ef4c 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -7,7 +7,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -15,7 +14,6 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/models/plex/plex_config.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_playlists_tab.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -23,14 +21,13 @@ import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('playlist-server'); @@ -187,43 +184,15 @@ Future _pumpTab( required VoidCallback onBack, required VoidCallback onSidebar, }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: onSidebar, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: ValueListenableBuilder( - valueListenable: harness.rebuild, - builder: (context, _, _) => - LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: ValueListenableBuilder( + valueListenable: harness.rebuild, + builder: (context, _, _) => LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), ), + size: const Size(800, 600), + focusSidebar: onSidebar, ); await tester.pumpAndSettle(); } diff --git a/test/screens/metadata_edit_screen_test.dart b/test/screens/metadata_edit_screen_test.dart index 074c4ca6..3f12cb3e 100644 --- a/test/screens/metadata_edit_screen_test.dart +++ b/test/screens/metadata_edit_screen_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:convert'; import 'dart:typed_data'; import 'package:drift/native.dart'; @@ -31,6 +30,7 @@ import 'package:plezy/widgets/loading_indicator_box.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/media_items.dart'; void main() { @@ -448,7 +448,7 @@ class _PlexMetadataRequests { path.startsWith('/library/metadata/') && request.url.queryParameters['includePreferences'] == '1') { final id = path.split('/').last; - return _jsonResponse({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [ { @@ -644,10 +644,6 @@ class _FakeFilePicker implements FilePickerDelegate { } } -http.Response _jsonResponse(Object body) { - return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); -} - http.Response _ok() => _response(200); http.Response _response(int statusCode) { diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 5fe0ebd0..a265fba9 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/search_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -28,6 +27,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -321,7 +321,7 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc for (final additionalClient in additionalClients) { manager.debugRegisterClientForTesting(additionalClient); } - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); final key = GlobalKey>(); diff --git a/test/services/catalog/plex_catalog_source_test.dart b/test/services/catalog/plex_catalog_source_test.dart index c276e9a1..eade4e42 100644 --- a/test/services/catalog/plex_catalog_source_test.dart +++ b/test/services/catalog/plex_catalog_source_test.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -12,10 +10,9 @@ import 'package:plezy/services/catalog/plex_catalog_source.dart'; import 'package:plezy/services/plex_discover_client.dart'; import 'package:plezy/utils/external_ids.dart'; -const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); +import '../../test_helpers/http_fixtures.dart'; -http.Response _json(Object body, [int status = 200]) => - http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); +const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); Map _metadata({ String ratingKey = 'plex-movie-1', @@ -51,7 +48,7 @@ void main() { _session, httpClient: MockClient((request) async { captured = request; - return _json({ + return jsonResponse({ 'MediaContainer': { 'offset': 25, 'size': 1, @@ -94,7 +91,7 @@ void main() { httpClient: MockClient((request) async { requests.add(request); if (request.url.path == '/hubs/sections/watchlist') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'Hub': [ { @@ -122,7 +119,7 @@ void main() { }); } if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'offset': 2, 'totalSize': 3, @@ -130,7 +127,7 @@ void main() { }, }); } - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); @@ -162,7 +159,7 @@ void main() { _session, httpClient: MockClient((request) async { requests.add(request); - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); @@ -181,7 +178,7 @@ void main() { _session, httpClient: MockClient((request) async { captured = request; - return _json({ + return jsonResponse({ 'MediaContainer': { 'SearchResults': [ { @@ -221,7 +218,7 @@ void main() { httpClient: MockClient((request) async { requests.add(request); if (request.url.path == '/library/sections/watchlist/all') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'totalSize': watchlisted ? 1 : 0, 'Metadata': watchlisted ? [_metadata()] : [], @@ -232,7 +229,7 @@ void main() { expect(request.url.path, '/actions/removeFromWatchlist'); expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); watchlisted = false; - return _json(const {}); + return jsonResponse(const {}); }), ), ); @@ -256,7 +253,7 @@ void main() { requests.add(request); if (request.url.path == '/library/metadata/matches') { expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [_metadata()], }, @@ -265,7 +262,7 @@ void main() { expect(request.method, 'PUT'); expect(request.url.path, '/actions/addToWatchlist'); expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); - return _json(const {}); + return jsonResponse(const {}); }), ), ); @@ -283,13 +280,13 @@ void main() { switch (request.url.path) { case '/library/metadata/matches': expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [_metadata(type: 'show')], }, }); case '/library/metadata/plex-movie-1': - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [ { @@ -302,7 +299,7 @@ void main() { }, }); case '/library/metadata/plex-movie-1/related': - return _json({ + return jsonResponse({ 'MediaContainer': { 'Hub': [ { @@ -312,7 +309,7 @@ void main() { }, }); } - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); diff --git a/test/services/catalog/seerr_catalog_source_test.dart b/test/services/catalog/seerr_catalog_source_test.dart index 30ae278a..6945e650 100644 --- a/test/services/catalog/seerr_catalog_source_test.dart +++ b/test/services/catalog/seerr_catalog_source_test.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; @@ -11,6 +8,8 @@ import 'package:plezy/services/catalog/seerr_catalog_source.dart'; import 'package:plezy/services/seerr/seerr_client.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/http_fixtures.dart'; + SeerrCatalogSource _source(MockClient mock) { final client = SeerrClient( const SeerrSession( @@ -36,15 +35,13 @@ SeerrCatalogSource _source(MockClient mock) { return source; } -http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); - void main() { group('SeerrCatalogSource', () { test('trending row keeps movies and shows, drops people, maps TMDB images', () async { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/discover/trending'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 3, 'results': [ @@ -89,7 +86,7 @@ void main() { final source = _source( MockClient((request) async { paths.add('${request.url.path}?${request.url.query}'); - return _json({ + return jsonResponse({ 'page': 2, 'totalPages': 2, 'results': [ @@ -106,13 +103,13 @@ void main() { }); test('rows Seerr does not serve throw', () { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(() => source.fetchRow(CatalogRowId.watchlist), throwsArgumentError); expect(() => source.fetchRow(CatalogRowId.suggestedAnime), throwsArgumentError); }); test('resolveItemIds needs a tmdb id', () async { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); final resolved = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 603, imdb: 'tt0133093')); expect(resolved?.tmdb, 603); expect(resolved?.imdb, 'tt0133093'); @@ -123,7 +120,7 @@ void main() { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/tv/1396'); - return _json({ + return jsonResponse({ 'id': 1396, 'name': 'Breaking Bad', 'credits': { @@ -156,7 +153,7 @@ void main() { MockClient((request) async { expect(request.url.path, '/api/v1/search'); expect(request.url.queryParameters['query'], 'the matrix'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 1, 'results': [ @@ -175,7 +172,7 @@ void main() { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/movie/603/recommendations'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 1, 'results': [ @@ -197,13 +194,13 @@ void main() { test('canRequest honors the per-kind permission split', () { // permissions: 2 = ADMIN in the fixture session → everything allowed. - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(source.canRequest(MediaKind.movie), isTrue); expect(source.canRequest(MediaKind.show), isTrue); }); test('has no watchlist: membership unknown, mutations unsupported', () async { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(source.supportsWatchlist, isFalse); expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull); expect(() => source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsUnsupportedError); diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index f0149417..8d479b83 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -6,7 +6,6 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/models/external_player_models.dart'; import 'package:plezy/services/external_player_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -15,8 +14,9 @@ import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/playback_report_fakes.dart'; -class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { +class _RecordingClient with PlaybackReportRecorder implements MediaServerClient, ScopedMediaServerClient { _RecordingClient({this.backend = MediaBackend.plex, String? scopedServerId}) : scopedServerId = scopedServerId ?? @@ -48,33 +48,18 @@ class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { bool get marksWatchedOnPlaybackStopped => backend == MediaBackend.jellyfin; @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - started.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - if (failStart) throw StateError('start failed'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - stopped.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - if (failStop) throw StateError('stop failed'); + Future onPlaybackReport(PlaybackReportCall call) async { + final entry = (positionMs: call.position.inMilliseconds, durationMs: call.duration?.inMilliseconds); + switch (call.kind) { + case PlaybackReportKind.started: + started.add(entry); + if (failStart) throw StateError('start failed'); + case PlaybackReportKind.progress: + throw UnimplementedError(); + case PlaybackReportKind.stopped: + stopped.add(entry); + if (failStop) throw StateError('stop failed'); + } } @override diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 3713b6e0..3ac9e576 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -19,6 +19,7 @@ import 'package:plezy/utils/device_identity.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/media_items.dart'; @@ -50,13 +51,7 @@ JellyfinClient _clientWithPlaybackInfo( connection: _conn(), httpClient: MockClient((request) { if (request.url.path == '/Users/user-1/Items/item-1') { - return Future.value( - http.Response( - jsonEncode({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources}), - 200, - headers: {'content-type': 'application/json'}, - ), - ); + return Future.value(jsonResponse({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources})); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return playbackInfo(request); @@ -66,6 +61,23 @@ JellyfinClient _clientWithPlaybackInfo( ); } +/// Serves [routes] as JSON keyed by request path and records the last URL seen +/// for each path; every other path answers 404. +({JellyfinClient client, Map requests}) _routedClient(Map routes) { + final requests = {}; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + final body = routes[req.url.path]; + if (body == null) return http.Response('not found', 404); + requests[req.url.path] = req.url; + return jsonResponse(body); + }), + ); + addTearDown(client.close); + return (client: client, requests: requests); +} + /// URL-builder smoke tests. We can't unit-test a network round-trip without /// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: /// query parameters must include the right keys and the auth token. These @@ -212,38 +224,30 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/$encodedItemId/LocalTrailers') { - return http.Response( - jsonEncode([ - { - 'Id': 'trailer-1', - 'Name': 'Trailer', - 'Type': 'Trailer', - 'ExtraType': 'Trailer', - 'RunTimeTicks': 900000000, - 'ImageTags': {'Primary': 'trailer-tag'}, - }, - {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse([ + { + 'Id': 'trailer-1', + 'Name': 'Trailer', + 'Type': 'Trailer', + 'ExtraType': 'Trailer', + 'RunTimeTicks': 900000000, + 'ImageTags': {'Primary': 'trailer-tag'}, + }, + {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, + ]); } if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') { - return http.Response( - jsonEncode([ - {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, - { - 'Id': 'featurette-1', - 'Name': 'Making Of', - 'Type': 'Video', - 'ExtraType': 'Featurette', - 'RunTimeTicks': 1800000000, - 'BackdropImageTags': ['featurette-backdrop'], - }, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse([ + {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, + { + 'Id': 'featurette-1', + 'Name': 'Making Of', + 'Type': 'Video', + 'ExtraType': 'Featurette', + 'RunTimeTicks': 1800000000, + 'BackdropImageTags': ['featurette-backdrop'], + }, + ]); } return http.Response('unexpected ${request.url}', 500); }), @@ -405,57 +409,49 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'MediaStreams': []}, - { - 'Id': 'src-2', - 'MediaStreams': [ - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayLanguage': 'English', - 'DisplayTitle': 'English - SRT', - 'IsExternal': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', - }, - { - 'Index': 4, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'fra', - 'DisplayLanguage': 'French', - 'DisplayTitle': 'French - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + { + 'Id': 'src-2', + 'MediaStreams': [ + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', + }, + { + 'Index': 4, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'fra', + 'DisplayLanguage': 'French', + 'DisplayTitle': 'French - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -503,14 +499,7 @@ void main() { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), - ( - 'malformed success', - (_) async => http.Response( - jsonEncode({'MediaSources': 'invalid'}), - 200, - headers: {'content-type': 'application/json'}, - ), - ), + ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { @@ -530,15 +519,11 @@ void main() { test('resolveDownload keeps the static stream when subtitle metadata is malformed', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), ); addTearDown(scoped.close); @@ -569,14 +554,7 @@ void main() { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), - ( - 'malformed success', - (_) async => http.Response( - jsonEncode({'MediaSources': 'invalid'}), - 200, - headers: {'content-type': 'application/json'}, - ), - ), + ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { @@ -635,19 +613,15 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, - {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); } return http.Response('{}', 404); }), @@ -672,46 +646,38 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUris.add(request.url); playbackInfoBodies.add(request.body); - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Id': 'src-1', - 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, - { - 'Index': 2, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -762,33 +728,25 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }); } return http.Response('{}', 404); }), @@ -826,57 +784,49 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'DefaultAudioStreamIndex': 1, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - 'TranscodingUrl': - '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'IsExternal': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 1, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -931,77 +881,69 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, - {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, - {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, + {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'DefaultSubtitleStreamIndex': 4, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - }, - { - 'Index': 4, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'fra', - 'DisplayTitle': 'French - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', - }, - { - 'Index': 5, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English Forced - SRT', - 'IsForced': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 4, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + { + 'Index': 4, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'fra', + 'DisplayTitle': 'French - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', + }, + { + 'Index': 5, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English Forced - SRT', + 'IsForced': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -1075,42 +1017,34 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Id': 'src-1', - 'TranscodingUrl': - '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -1152,39 +1086,31 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, - {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1226,46 +1152,38 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, - ], - }, - { - 'Id': 'src-2', - 'Container': 'mp4', - 'DefaultAudioStreamIndex': 8, - 'MediaStreams': [ - {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, + ], + }, + { + 'Id': 'src-2', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 8, + 'MediaStreams': [ + {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-2'}, + ], + }); } return http.Response('{}', 404); }), @@ -1303,44 +1221,36 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, - ], - }, - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, + ], + }, + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1080'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1080'}, + ], + }); } return http.Response('{}', 404); }), @@ -1376,44 +1286,36 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'item-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'item-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'item-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'item-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1448,47 +1350,39 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'wrong-session', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'wrong-session', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', + }, + ], + }); } return http.Response('{}', 404); }), @@ -1516,10 +1410,7 @@ void main() { ); test('empty successful negotiation falls back to the static VOD stream', () async { - final scoped = _clientWithPlaybackInfo( - (_) async => - http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}), - ); + final scoped = _clientWithPlaybackInfo((_) async => jsonResponse({'MediaSources': []})); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( @@ -1540,15 +1431,11 @@ void main() { test('applicable source without negotiated URL falls back to static direct play', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), ); addTearDown(scoped.close); @@ -1627,7 +1514,7 @@ void main() { for (final testCase in cases) { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response(jsonEncode(testCase.response), 200, headers: {'content-type': 'application/json'}), + (_) async => jsonResponse(testCase.response), itemSources: testCase.itemSources, ); addTearDown(scoped.close); @@ -1640,15 +1527,11 @@ void main() { test('matching download source with empty streams is a complete empty-sidecar plan', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + ], + }), ); addTearDown(scoped.close); @@ -1683,7 +1566,7 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); @@ -1701,7 +1584,7 @@ void main() { httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.body; - return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); @@ -1746,7 +1629,7 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { captured.add(request.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }), ); addTearDown(scoped.close); @@ -1805,29 +1688,21 @@ void main() { connection: _conn(accessToken: 'tok+with spaces/?&'), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1856,35 +1731,27 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1919,21 +1786,17 @@ void main() { requests.add(request.url); capturedBody = request.body; if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'live-session-1', - 'MediaSources': [ - { - 'Id': 'source-1', - 'Container': 'ts', - 'LiveStreamId': 'open-stream-1', - 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'live-session-1', + 'MediaSources': [ + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'open-stream-1', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', + }, + ], + }); } return http.Response('{}', 404); }), @@ -1970,19 +1833,15 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Container': 'ts', - 'TranscodingUrl': - '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Container': 'ts', + 'TranscodingUrl': + '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', + }, + ], + }); } return http.Response('{}', 404); }), @@ -2003,15 +1862,11 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, + ], + }); } return http.Response('{}', 404); }), @@ -2077,33 +1932,25 @@ void main() { connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: MockClient((request) async { if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }); } return http.Response('{}', 404); }), @@ -2140,48 +1987,40 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-trickplay') { - return http.Response( - jsonEncode({ - 'Id': 'item-trickplay', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, - {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, - ], - 'Trickplay': { - 'src-a': { - '160': { - 'Width': 160, - 'Height': 90, - 'TileWidth': 4, - 'TileHeight': 4, - 'ThumbnailCount': 16, - 'Interval': 10000, - }, + return jsonResponse({ + 'Id': 'item-trickplay', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, + {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, + ], + 'Trickplay': { + 'src-a': { + '160': { + 'Width': 160, + 'Height': 90, + 'TileWidth': 4, + 'TileHeight': 4, + 'ThumbnailCount': 16, + 'Interval': 10000, }, }, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + }, + }); } if (request.url.path == '/Items/item-trickplay/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-b', - 'MediaSources': [ - { - 'Id': 'src-b', - 'Container': 'mp4', - 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', - 'MediaStreams': [], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-b', + 'MediaSources': [ + { + 'Id': 'src-b', + 'Container': 'mp4', + 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', + 'MediaStreams': [], + }, + ], + }); } return http.Response('{}', 404); }), @@ -2269,21 +2108,17 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'movie-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], - }, - ], - 'TotalRecordCount': 123, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'movie-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], + }, + ], + 'TotalRecordCount': 123, + }); }), ); addTearDown(scoped.close); @@ -2318,11 +2153,7 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); @@ -2354,16 +2185,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Genres': ['Drama', 'Action'], - 'OfficialRatings': ['PG-13'], - 'Tags': ['Holiday'], - 'Years': [2024, 1999], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Genres': ['Drama', 'Action'], + 'OfficialRatings': ['PG-13'], + 'Tags': ['Holiday'], + 'Years': [2024, 1999], + }); }), ); addTearDown(scoped.close); @@ -2402,15 +2229,11 @@ void main() { httpClient: MockClient((req) async { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final limit = int.parse(req.url.queryParameters['Limit'] ?? '25'); - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }); }), ); addTearDown(scoped.close); @@ -2430,11 +2253,7 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); @@ -2468,11 +2287,7 @@ void main() { captured.add(req.url); final foldersOnly = req.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder'; final items = allChildren.where((c) => (c['Type'] == 'Folder') == foldersOnly).toList(); - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': items.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); }), ); addTearDown(scoped.close); @@ -2519,27 +2334,19 @@ void main() { httpClient: MockClient((req) async { if (req.url.queryParameters.containsKey('IncludeItemTypes')) { // Folders query — this directory has none. - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); } mediaStarts.add(req.url.queryParameters['StartIndex']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < end; i++) - {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, - ], - 'TotalRecordCount': total, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < end; i++) + {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, + ], + 'TotalRecordCount': total, + }); }), ); addTearDown(scoped.close); @@ -2572,16 +2379,12 @@ void main() { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, - ], - 'TotalRecordCount': total, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, + ], + 'TotalRecordCount': total, + }); }), ); addTearDown(scoped.close); @@ -2623,11 +2426,7 @@ void main() { 'UserData': {'PlayCount': 0}, }, ]; - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': total}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': total}); }), ); addTearDown(pagedClient.close); @@ -2646,16 +2445,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, - ], - 'TotalRecordCount': 1, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, + ], + 'TotalRecordCount': 1, + }); }), ); addTearDown(scoped.close); @@ -2682,15 +2477,11 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/show-1') { - return http.Response( - jsonEncode({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}); } if (req.url.path == '/Shows/NextUp') { capturedNextUp = req.url; - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } return http.Response('not found', 404); }), @@ -2715,23 +2506,15 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}); } if (req.url.path == '/MediaSegments/item-1') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, - {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, + {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, + ], + }); } return http.Response('not found', 404); }), @@ -2751,21 +2534,17 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Episode', - 'Name': 'Episode', - 'RunTimeTicks': 1200000000, - 'Chapters': [ - {'Name': 'OP', 'StartPositionTicks': 100000000}, - {'Name': 'Episode', 'StartPositionTicks': 450000000}, - {'Name': 'ED', 'StartPositionTicks': 900000000}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'RunTimeTicks': 1200000000, + 'Chapters': [ + {'Name': 'OP', 'StartPositionTicks': 100000000}, + {'Name': 'Episode', 'StartPositionTicks': 450000000}, + {'Name': 'ED', 'StartPositionTicks': 900000000}, + ], + }); } if (req.url.path == '/MediaSegments/item-1') { return http.Response('not found', 404); @@ -2789,28 +2568,20 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, - {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }); } return http.Response('not found', 404); }), @@ -2845,47 +2616,35 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'resume-old', - 'Type': 'Movie', - 'Name': 'Old Movie', - 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'resume-old', + 'Type': 'Movie', + 'Name': 'Old Movie', + 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, + }, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'ep-played', - 'Type': 'Episode', - 'SeriesId': 'show-recent', - 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }); } return http.Response('not found', 404); }), @@ -2915,53 +2674,41 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'resume-old-1', - 'Type': 'Movie', - 'Name': 'Old Movie 1', - 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, - }, - { - 'Id': 'resume-old-2', - 'Type': 'Movie', - 'Name': 'Old Movie 2', - 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'resume-old-1', + 'Type': 'Movie', + 'Name': 'Old Movie 1', + 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, + }, + { + 'Id': 'resume-old-2', + 'Type': 'Movie', + 'Name': 'Old Movie 2', + 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, + }, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'ep-played', - 'Type': 'Episode', - 'SeriesId': 'show-recent', - 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }); } return http.Response('not found', 404); }), @@ -2980,15 +2727,11 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { return http.Response('server error', 500); @@ -3010,28 +2753,20 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, - {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }); } return http.Response('not found', 404); }), @@ -3055,7 +2790,7 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } @@ -3066,15 +2801,11 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -3124,7 +2855,7 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } @@ -3336,16 +3067,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 30, - 'Items': [ - {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 30, + 'Items': [ + {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, + ], + }); }), ); addTearDown(client.close); @@ -3368,16 +3095,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 321, - 'Items': [ - {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 321, + 'Items': [ + {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, + ], + }); }), ); addTearDown(client.close); @@ -3436,28 +3159,20 @@ void main() { final mock = MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'TotalRecordCount': 1, - 'Items': [ - {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 1, + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3491,28 +3206,20 @@ void main() { Uri? itemsRequest; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { itemsRequest = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 30, - 'Items': [ - {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 30, + 'Items': [ + {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3534,27 +3241,19 @@ void main() { test('fetchCollectionsPage uses sentinel total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, - {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3571,29 +3270,21 @@ void main() { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = req.url.queryParameters['StartIndex']; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 2, - 'Items': [ - {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 2, + 'Items': [ + {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3611,19 +3302,15 @@ void main() { var itemsRequested = false; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + ], + }); } if (req.url.path == '/Items') { itemsRequested = true; - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } return http.Response('not found', 404); }); @@ -3641,16 +3328,12 @@ void main() { final mock = MockClient((req) async { if (req.url.path == '/Items') { itemsRequest = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 25, - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 25, + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, + ], + }); } return http.Response('not found', 404); }); @@ -3707,176 +3390,119 @@ void main() { group('JellyfinClient paged media lists', () { test('fetchPersonMediaPage uses requested page bounds', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchPersonMediaPage('person-1', start: 20, size: 10); + final page = await routed.client.fetchPersonMediaPage('person-1', start: 20, size: 10); expect(page.items.single.id, 'movie-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['PersonIds'], 'person-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['PersonIds'], 'person-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); }); test('fetchPlayableDescendantsPage uses requested page bounds', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); + final page = await routed.client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'show-1'); - expect(requestUri!.queryParameters['Recursive'], 'true'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'show-1'); + expect(query['Recursive'], 'true'); // Audio rides along so albums/artists/audio playlists expand to tracks. - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Audio'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); + expect(query['IncludeItemTypes'], 'Movie,Episode,Audio'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); }); test('fetchSeasonEpisodesPage uses Jellyfin episode endpoint scoped to season', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Shows/show-1/Episodes') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Shows/show-1/Episodes': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); + final page = await routed.client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['SeasonId'], 'season-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); - expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); - expect(requestUri!.queryParameters['IsMissing'], 'false'); - expect(requestUri!.queryParameters['IsVirtualUnaired'], 'false'); - expect(requestUri!.queryParameters['Fields']!.split(','), contains('MediaSources')); - expect(requestUri!.queryParameters.containsKey('SortBy'), isFalse); - expect(requestUri!.queryParameters.containsKey('SortOrder'), isFalse); + final query = routed.requests['/Shows/show-1/Episodes']!.queryParameters; + expect(query['SeasonId'], 'season-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); + expect(query['EnableTotalRecordCount'], 'true'); + expect(query['IsMissing'], 'false'); + expect(query['IsVirtualUnaired'], 'false'); + expect(query['Fields']!.split(','), contains('MediaSources')); + expect(query.containsKey('SortBy'), isFalse); + expect(query.containsKey('SortOrder'), isFalse); }); test('fetchChildrenPage orders direct episode children by season and episode index', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Shows/season-1/Seasons') { - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); - } - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Shows/season-1/Seasons': {'Items': []}, + '/Items': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchChildrenPage('season-1', start: 20, size: 10); + final page = await routed.client.fetchChildrenPage('season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'season-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); - expect(requestUri!.queryParameters['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); - expect(requestUri!.queryParameters['SortOrder'], 'Ascending,Ascending,Ascending'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'season-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); + expect(query['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); + expect(query['SortOrder'], 'Ascending,Ascending,Ascending'); }); test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, - ], - 'TotalRecordCount': 1, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, + ], + 'TotalRecordCount': 1, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final items = await client.fetchPlayableFolderDescendants('folder-1'); + final items = await routed.client.fetchPlayableFolderDescendants('folder-1'); expect(items.single.kind, MediaKind.clip); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'folder-1'); - expect(requestUri!.queryParameters['Recursive'], 'true'); - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); - expect(requestUri!.queryParameters['IncludeItemTypes'], isNot(contains('Audio'))); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'folder-1'); + expect(query['Recursive'], 'true'); + expect(query['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); + expect(query['IncludeItemTypes'], isNot(contains('Audio'))); }); test('fetchPlayableDescendants cancellation stops before a second page', () async { @@ -3887,14 +3513,10 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), + 'TotalRecordCount': 501, + }); }), ); addTearDown(client.close); @@ -3914,14 +3536,10 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), + 'TotalRecordCount': 501, + }); }), ); addTearDown(client.close); @@ -3941,17 +3559,13 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate( - 200, - (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, - ), - 'TotalRecordCount': 201, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate( + 200, + (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, + ), + 'TotalRecordCount': 201, + }); }), ); addTearDown(client.close); @@ -3967,23 +3581,16 @@ void main() { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Shows/season-1/Seasons') { - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final count = start == 0 ? 500 : 1; - return http.Response( - jsonEncode({ - 'Items': List.generate( - count, - (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}, - ), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(count, (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}), + 'TotalRecordCount': 501, + }); } return http.Response('not found', 404); }); @@ -4012,11 +3619,7 @@ void main() { if (requestedMediaType == null) return true; return (item['MediaType'] as String).toLowerCase() == requestedMediaType; }).toList(); - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': items.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); } return http.Response('not found', 404); }); @@ -4050,14 +3653,10 @@ void main() { final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(filtered, start: start, size: limit), - 'TotalRecordCount': filtered.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4092,14 +3691,10 @@ void main() { final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(filtered, start: start, size: limit), - 'TotalRecordCount': filtered.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4128,11 +3723,10 @@ void main() { requests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({'Items': sliceFakePage(videos, start: start, size: limit), 'TotalRecordCount': videos.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(videos, start: start, size: limit), + 'TotalRecordCount': videos.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4170,16 +3764,12 @@ void main() { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, + ], + 'TotalRecordCount': 40, + }); } return http.Response('not found', 404); }); @@ -4199,13 +3789,9 @@ void main() { test('fetchPlaylistPage uses minimal fallback total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { - return http.Response( - jsonEncode({ - 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), + }); } return http.Response('not found', 404); }); @@ -4222,21 +3808,17 @@ void main() { test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async { final mock = MockClient((req) async { if (req.url.path == '/jellyfin/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'video-1', - 'Name': 'Video Playlist', - 'Type': 'Playlist', - 'MediaType': 'Video', - 'ImageTags': {'Primary': 'tag 1'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'video-1', + 'Name': 'Video Playlist', + 'Type': 'Playlist', + 'MediaType': 'Video', + 'ImageTags': {'Primary': 'tag 1'}, + }, + ], + }); } return http.Response('not found', 404); }); @@ -4260,16 +3842,12 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response( - jsonEncode({ - 'Id': 'folder/item #1?x', - 'Name': 'Movie', - 'Type': 'Movie', - 'ProviderIds': {'Tmdb': '1'}, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'folder/item #1?x', + 'Name': 'Movie', + 'Type': 'Movie', + 'ProviderIds': {'Tmdb': '1'}, + }); }), ); addTearDown(client.close); @@ -4317,17 +3895,13 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/item-1/RemoteImages') { - return http.Response( - jsonEncode({ - 'TotalRecordCount': 1, - 'Providers': ['TheMovieDb'], - 'Images': [ - {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 1, + 'Providers': ['TheMovieDb'], + 'Images': [ + {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, + ], + }); } return http.Response('', 204); }), diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index 8c8a18f5..120538f9 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -1,57 +1,27 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/live_session_tracker.dart'; -class _FakeJellyfinClient implements JellyfinClient { +import '../test_helpers/playback_report_fakes.dart'; + +class _FakeJellyfinClient with PlaybackReportRecorder implements JellyfinClient { final calls = []; final startGate = Completer(); @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - await startGate.future; - calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId:$playMethod'); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - calls.add('stopped:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); + Future onPlaybackReport(PlaybackReportCall call) async { + final identity = '${call.itemId}:${call.playSessionId}:${call.mediaSourceId}:${call.liveStreamId}'; + switch (call.kind) { + case PlaybackReportKind.started: + await startGate.future; + calls.add('started:$identity:${call.playMethod}'); + case PlaybackReportKind.progress: + calls.add('${call.isPaused ? 'paused' : 'playing'}:$identity'); + case PlaybackReportKind.stopped: + calls.add('stopped:$identity'); + } } @override diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 5a798c95..26834950 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -8,7 +8,6 @@ import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/mpv/models.dart'; import 'package:plezy/mpv/player/player.dart'; import 'package:plezy/mpv/player/player_state.dart'; @@ -20,6 +19,7 @@ import 'package:plezy/services/music/music_playback_service_impl.dart'; import 'package:plezy/services/music/music_source_resolver.dart'; import 'package:plezy/services/playback_coordinator.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/playback_report_fakes.dart'; const _trackDuration = Duration(minutes: 3); @@ -351,7 +351,7 @@ class RecordedReport { /// Records the progress-report surface; everything else is unimplemented /// (the engine and tracker never touch it in these tests). -class FakeMediaServerClient extends Fake implements MediaServerClient { +class FakeMediaServerClient extends Fake with PlaybackReportRecorder implements MediaServerClient { final List reports = []; final List markedWatched = []; Completer>? instantMixGate; @@ -381,47 +381,13 @@ class FakeMediaServerClient extends Fake implements MediaServerClient { } @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - reports.add(RecordedReport('started', itemId, position)); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - reports.add(RecordedReport(isPaused ? 'paused' : 'progress', itemId, position)); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - reports.add(RecordedReport('stopped', itemId, position)); + Future onPlaybackReport(PlaybackReportCall call) async { + final state = switch (call.kind) { + PlaybackReportKind.started => 'started', + PlaybackReportKind.progress => call.isPaused ? 'paused' : 'progress', + PlaybackReportKind.stopped => 'stopped', + }; + reports.add(RecordedReport(state, call.itemId, call.position)); } } diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 5e13f227..8c65fad9 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -22,6 +22,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/playback_report_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; @@ -55,7 +56,7 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource bool get hasListeners => super.hasListeners; } -class _RecordingMediaClient implements MediaServerClient { +class _RecordingMediaClient with PlaybackReportRecorder implements MediaServerClient { _RecordingMediaClient({required this.serverId, required this.backend}); @override @@ -85,36 +86,24 @@ class _RecordingMediaClient implements MediaServerClient { testMediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId); @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - started.add((itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - stopped.add(( - itemId: itemId, - positionMs: position.inMilliseconds, - durationMs: duration?.inMilliseconds, - report: report, - )); + Future onPlaybackReport(PlaybackReportCall call) async { + switch (call.kind) { + case PlaybackReportKind.started: + started.add(( + itemId: call.itemId, + positionMs: call.position.inMilliseconds, + durationMs: call.duration?.inMilliseconds, + )); + case PlaybackReportKind.progress: + throw UnimplementedError(); + case PlaybackReportKind.stopped: + stopped.add(( + itemId: call.itemId, + positionMs: call.position.inMilliseconds, + durationMs: call.duration?.inMilliseconds, + report: call.report, + )); + } } @override diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 684dfd4e..565f8f9c 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/playback_report_fakes.dart'; // Periodic behavior is virtualized with fake_async and the tracker's existing // updateInterval seam. Routing, threshold, scrobble, cadence, coalescing, @@ -85,7 +86,7 @@ class _FakePlayer implements Player { /// Recording fake [PlexClient] that captures every progress / scrobble call /// without touching the network. -class _FakePlexClient implements PlexClient { +class _FakePlexClient with PlaybackReportRecorder implements PlexClient { _FakePlexClient({this.thresholdPercent = 90}); /// Watched-threshold percentage to report. Defaults to 90 (matches @@ -149,68 +150,25 @@ class _FakePlexClient implements PlexClient { // The interface report* methods delegate to updateProgress so existing // assertions on `updateProgressCalls` keep working. @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) { - playbackSessionIds.add(playSessionId); + Future onPlaybackReport(PlaybackReportCall call) { + playbackSessionIds.add(call.playSessionId); playbackStreamSelections.add(( - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - )); - return updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) { - playbackSessionIds.add(playSessionId); - playbackStreamSelections.add(( - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, + mediaSourceId: call.mediaSourceId, + audioStreamIndex: call.audioStreamIndex, + subtitleStreamIndex: call.subtitleStreamIndex, )); return updateProgress( - itemId, - time: position.inMilliseconds, - state: isPaused ? 'paused' : 'playing', - duration: duration.inMilliseconds, + call.itemId, + time: call.position.inMilliseconds, + state: switch (call.kind) { + PlaybackReportKind.started => 'playing', + PlaybackReportKind.progress => call.isPaused ? 'paused' : 'playing', + PlaybackReportKind.stopped => 'stopped', + }, + duration: call.duration?.inMilliseconds, ); } - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) { - playbackSessionIds.add(playSessionId); - playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null)); - return updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); - } - // Transport-only, like production: the single watch event for the stop // flow is emitted by markWatchedFromPlaybackStop after this returns. @override @@ -1431,7 +1389,7 @@ void main() { /// A more precise fake than [_FakePlexClient]: lets the test independently /// fail the scrobble (markWatched) without touching the progress signals. -class _ScrobblePreciseClient implements PlexClient { +class _ScrobblePreciseClient with PlaybackReportRecorder implements PlexClient { _ScrobblePreciseClient({this.thresholdPercent = 90, this.failScrobbleFirstTime = false}); final int thresholdPercent; @@ -1472,42 +1430,7 @@ class _ScrobblePreciseClient implements PlexClient { }) async {} @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async {} - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async {} - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async {} + Future onPlaybackReport(PlaybackReportCall call) async {} @override Future markWatched(MediaItem item) async { diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 848fa435..f538a28d 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -2,66 +2,36 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/playback_report_session.dart'; -class _RecordingClient implements MediaServerClient { +import '../test_helpers/playback_report_fakes.dart'; + +class _RecordingClient with PlaybackReportRecorder implements MediaServerClient { final calls = []; Completer? startGate; Completer? stopGate; bool failNextStop = false; @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final gate = startGate; - if (gate != null) await gate.future; - calls.add('started:${position.inMilliseconds}:$mediaSourceId:$audioStreamIndex:$subtitleStreamIndex'); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:${position.inMilliseconds}'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); - final gate = stopGate; - if (gate != null) await gate.future; - if (failNextStop) { - failNextStop = false; - throw StateError('stop failed'); + Future onPlaybackReport(PlaybackReportCall call) async { + final positionMs = call.position.inMilliseconds; + switch (call.kind) { + case PlaybackReportKind.started: + final start = startGate; + if (start != null) await start.future; + calls.add('started:$positionMs:${call.mediaSourceId}:${call.audioStreamIndex}:${call.subtitleStreamIndex}'); + case PlaybackReportKind.progress: + calls.add('${call.isPaused ? 'paused' : 'playing'}:$positionMs'); + case PlaybackReportKind.stopped: + calls.add('stopped-attempt:$positionMs:${call.mediaSourceId}'); + final stop = stopGate; + if (stop != null) await stop.future; + if (failNextStop) { + failNextStop = false; + throw StateError('stop failed'); + } + calls.add('stopped:$positionMs:${call.mediaSourceId}'); } - calls.add('stopped:${position.inMilliseconds}:$mediaSourceId'); } @override diff --git a/test/test_helpers/http_fixtures.dart b/test/test_helpers/http_fixtures.dart new file mode 100644 index 00000000..a9d7d3ce --- /dev/null +++ b/test/test_helpers/http_fixtures.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// JSON-encodes [body] into an [http.Response] carrying a JSON content type. +http.Response jsonResponse(Object body, {int status = 200}) => + http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); diff --git a/test/test_helpers/library_tab_scaffold.dart b/test/test_helpers/library_tab_scaffold.dart new file mode 100644 index 00000000..9f58419a --- /dev/null +++ b/test/test_helpers/library_tab_scaffold.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/navigation/main_screen_scope.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:provider/provider.dart'; + +/// Pumps [tab] under the ancestors every library tab requires: [provider], an +/// [InputModeTracker], a [MainScreenFocusScope] and a [NestedScrollView] whose +/// overlap absorber handle the tabs look up. Sizes the view to [size] and +/// restores it when the test ends. Settling is left to the caller. +Future pumpLibraryTab( + WidgetTester tester, { + required MultiServerProvider provider, + required Widget tab, + Size size = const Size(1280, 720), + VoidCallback? focusSidebar, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: focusSidebar ?? () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: 0, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), + ), + ], + body: tab, + ), + ), + ), + ), + ), + ), + ); +} + +/// Frames a library tab needs to issue its debounced request and apply the +/// response, for tabs whose loading never quiesces enough for `pumpAndSettle`. +Future pumpRequestFrames(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 500)); +} diff --git a/test/test_helpers/multi_server_fixtures.dart b/test/test_helpers/multi_server_fixtures.dart new file mode 100644 index 00000000..bfbf16bf --- /dev/null +++ b/test/test_helpers/multi_server_fixtures.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; + +/// Wires [manager] into the provider widget tests read servers from. The caller +/// owns disposal of the returned provider. +MultiServerProvider testMultiServerProvider(MultiServerManager manager) { + return MultiServerProvider(manager, DataAggregationService(manager)); +} + +/// Registers [clients] on a fresh manager and returns it with its provider, +/// disposing both when the test ends. Clients also named in [offline] are +/// registered as unreachable; [clients] fixes the registration order. +({MultiServerManager manager, MultiServerProvider provider}) testMultiServer({ + List clients = const [], + List offline = const [], +}) { + final manager = MultiServerManager(); + for (final client in clients) { + manager.debugRegisterClientForTesting(client, online: !offline.contains(client)); + } + final provider = testMultiServerProvider(manager); + addTearDown(() { + provider.dispose(); + manager.dispose(); + }); + return (manager: manager, provider: provider); +} diff --git a/test/test_helpers/playback_report_fakes.dart b/test/test_helpers/playback_report_fakes.dart new file mode 100644 index 00000000..5c89d80d --- /dev/null +++ b/test/test_helpers/playback_report_fakes.dart @@ -0,0 +1,120 @@ +import 'package:plezy/media/playback_report_metadata.dart'; + +enum PlaybackReportKind { started, progress, stopped } + +/// One `reportPlayback*` invocation flattened into a single value. +class PlaybackReportCall { + const PlaybackReportCall({ + required this.kind, + required this.itemId, + required this.position, + this.duration, + this.isPaused = false, + this.playSessionId, + this.playMethod, + this.liveStreamId, + this.mediaSourceId, + this.audioStreamIndex, + this.subtitleStreamIndex, + this.report = const PlaybackReportMetadata.live(), + }); + + final PlaybackReportKind kind; + final String itemId; + final Duration position; + final Duration? duration; + final bool isPaused; + final String? playSessionId; + final String? playMethod; + final String? liveStreamId; + final String? mediaSourceId; + final int? audioStreamIndex; + final int? subtitleStreamIndex; + final PlaybackReportMetadata report; +} + +/// Carries the three playback-reporting signatures so fakes implement the +/// surface once in [onPlaybackReport]. Forwarding is synchronous, so +/// [onPlaybackReport] runs with the same timing the overridden method had. +mixin PlaybackReportRecorder { + Future onPlaybackReport(PlaybackReportCall call); + + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.started, + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ), + ); + } + + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.progress, + itemId: itemId, + position: position, + duration: duration, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ), + ); + } + + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? liveStreamId, + String? mediaSourceId, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.stopped, + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + report: report, + ), + ); + } +} diff --git a/test/test_helpers/profile_stack.dart b/test/test_helpers/profile_stack.dart new file mode 100644 index 00000000..b7d7ecca --- /dev/null +++ b/test/test_helpers/profile_stack.dart @@ -0,0 +1,74 @@ +import 'package:drift/native.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.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/services/storage_service.dart'; + +/// The profile dependency graph wired the way production wires it: database → +/// registries → [PlexHomeService] → [ActiveProfileProvider]. +class ProfileStack { + ProfileStack._({ + required this.db, + required this.connections, + required this.profileConnections, + required this.profiles, + required this.plexHome, + required this.active, + required this._storage, + required this._ownsDatabase, + }); + + final AppDatabase db; + final ConnectionRegistry connections; + final ProfileConnectionRegistry profileConnections; + final ProfileRegistry profiles; + final PlexHomeService plexHome; + final ActiveProfileProvider active; + + final StorageService? _storage; + final bool _ownsDatabase; + + /// Only wired when the stack was created with `withStorage: true`. + StorageService get storage => _storage!; + + /// Pass [db] when the test also needs the database for caches or downloads; + /// the caller then owns closing it. + static Future create({ + AppDatabase? db, + List homeUsers = const [], + bool withStorage = true, + }) async { + final database = db ?? AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final profiles = ProfileRegistry(database); + final storage = withStorage ? await StorageService.getInstance() : null; + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => homeUsers, + ); + return ProfileStack._( + db: database, + connections: connections, + profileConnections: profileConnections, + profiles: profiles, + plexHome: plexHome, + active: ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage), + storage: storage, + ownsDatabase: db == null, + ); + } + + Future dispose() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + if (_ownsDatabase) await db.close(); + } +} diff --git a/test/test_helpers/theme.dart b/test/test_helpers/theme.dart new file mode 100644 index 00000000..170671b3 --- /dev/null +++ b/test/test_helpers/theme.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/theme/mono_tokens.dart'; + +/// Default [MonoTokens] for widget tests: production-shaped metrics with +/// 1ms animations and no ink splashes, so a single `pump` settles the tree. +/// +/// Use as `ThemeData(extensions: const [testMonoTokens])`. +const testMonoTokens = MonoTokens( + radiusSm: 8, + radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 1), + normal: Duration(milliseconds: 1), + slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), + bg: Colors.black, + surface: Colors.black, + outline: Colors.white24, + text: Colors.white, + textMuted: Colors.white70, + splashFactory: NoSplash.splashFactory, +); + +/// [testMonoTokens] with realistic animation durations, for tests that step +/// through intermediate frames instead of settling straight to the end state. +const testMonoTokensAnimated = MonoTokens( + radiusSm: 4, + radiusMd: 8, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 100), + normal: Duration(milliseconds: 200), + slow: Duration(milliseconds: 300), + expressive: Duration(milliseconds: 300), + bg: Colors.black, + surface: Color(0xFF111111), + outline: Color(0xFF333333), + text: Colors.white, + textMuted: Color(0xFFAAAAAA), + splashFactory: NoSplash.splashFactory, +); diff --git a/test/utils/provider_extensions_test.dart b/test/utils/provider_extensions_test.dart index 19b7ac20..afaab4a8 100644 --- a/test/utils/provider_extensions_test.dart +++ b/test/utils/provider_extensions_test.dart @@ -8,13 +8,12 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/provider_extensions.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; const _missingOwnerLibrary = MediaLibrary( id: '1', @@ -59,13 +58,7 @@ void main() { tester, ) async { final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext(tester, testMultiServer(clients: [replacement]).provider); expect(() => context.getPlexClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); expect(() => context.getMediaClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); @@ -75,13 +68,7 @@ void main() { tester, ) async { final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext(tester, testMultiServer(clients: [replacement]).provider); for (final serverId in [null, ' ']) { final library = MediaLibrary( @@ -103,15 +90,10 @@ void main() { testWidgets('library-qualified helpers return their registered owner even when it is marked offline', (tester) async { final owner = testPlexClient(serverId: ServerId('server-a')); final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager() - ..debugRegisterClientForTesting(owner, online: false) - ..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext( + tester, + testMultiServer(clients: [owner, replacement], offline: [owner]).provider, + ); expect(context.getPlexClientForLibrary(_missingOwnerLibrary), same(owner)); expect(context.getMediaClientForLibrary(_missingOwnerLibrary), same(owner)); diff --git a/test/widgets/chapter_sheet_test.dart b/test/widgets/chapter_sheet_test.dart index 76bc0768..9540d902 100644 --- a/test/widgets/chapter_sheet_test.dart +++ b/test/widgets/chapter_sheet_test.dart @@ -4,28 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/sheets/chapter_sheet.dart'; -const _tokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); @@ -64,7 +46,7 @@ Future _pumpSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_tokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: Builder( diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index af1c3db5..63c38449 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -9,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.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/navigation/profile_navigation_scope.dart'; @@ -26,9 +25,6 @@ import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/profiles/profile.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/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; @@ -51,6 +47,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -126,28 +123,14 @@ void main() { ]; final client = _AudioPlaylistClient(tracks); final music = _RecordingMusicPlaybackService(); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); music.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -164,7 +147,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: music), ], child: MaterialApp( @@ -244,28 +227,14 @@ void main() { ), ])..blockWithAbort = true; final playback = PlaybackStateProvider(); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { playback.dispose(); - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -281,7 +250,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: playback), ], child: MaterialApp( @@ -323,27 +292,13 @@ void main() { TvDetectionService.debugSetAppleTVOverride(true); addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -360,7 +315,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -615,21 +570,9 @@ Future> _pumpPlexMovieMenu( ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(db: db, withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); await db.close(); @@ -648,7 +591,7 @@ Future> _pumpPlexMovieMenu( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -829,18 +772,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( final client = _RelatedMusicClient(relatedItems); final manager = MultiServerManager()..debugRegisterClientForTesting(client); final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(db: db, withStorage: false); final music = _RecordingMusicPlaybackService(); final rootNavigatorKey = GlobalKey(); final profileNavigatorKey = GlobalKey(); @@ -849,8 +781,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( addTearDown(() async { downloadProvider.dispose(); downloadManager.dispose(); - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); music.dispose(); multiServerProvider.dispose(); manager.dispose(); @@ -866,7 +797,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: downloadProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: music), ], child: ProfileNavigationScope( diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 2b8e069f..d114bccc 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -1,12 +1,9 @@ import 'dart:async'; -import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/focus/focusable_wrapper.dart'; import 'package:plezy/i18n/strings.g.dart'; @@ -15,9 +12,6 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/download_models.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/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; @@ -33,6 +27,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/profile_stack.dart'; final _track = testMediaItem( id: 'track_1', @@ -303,25 +298,11 @@ void main() { testWidgets('keyboard long-press anchors the context menu to the focused card instead of a stale pointer', ( tester, ) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); final downloadProvider = _FakeDownloadProvider(); addTearDown(() async { - activeProfileProvider.dispose(); + await stack.dispose(); downloadProvider.dispose(); - await plexHome.dispose(); - await db.close(); }); final service = _FakeMusicService(track: _track); final observer = MusicUiRouteObserver(); @@ -330,7 +311,7 @@ void main() { wrap( service: service, observer: observer, - activeProfileProvider: activeProfileProvider, + activeProfileProvider: stack.active, downloadProvider: downloadProvider, ), ); diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart index 21a0ac27..eb45d6c6 100644 --- a/test/widgets/player_queue_spoilers_test.dart +++ b/test/widgets/player_queue_spoilers_test.dart @@ -12,7 +12,6 @@ import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/sheets/queue_sheet.dart'; import 'package:plezy/widgets/video_controls/widgets/content_strip.dart'; import 'package:plezy/widgets/video_controls/widgets/media_selector_thumbnail.dart'; @@ -20,25 +19,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -334,7 +315,7 @@ Widget _queueHarness({required PlaybackStateProvider playback, required Widget c value: playback, child: InputModeTracker( child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)), ), ), diff --git a/test/widgets/server_activities_button_test.dart b/test/widgets/server_activities_button_test.dart index dfe410a1..fff44583 100644 --- a/test/widgets/server_activities_button_test.dart +++ b/test/widgets/server_activities_button_test.dart @@ -9,14 +9,13 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/widgets/server_activities_button.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -31,15 +30,9 @@ void main() { tearDown(() => database.close()); testWidgets('togglePanel opens and closes the server activities overlay', (tester) async { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServer().provider; final buttonKey = GlobalKey(); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); - await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( @@ -178,15 +171,9 @@ class _ActivitiesHarness { Future<_ActivitiesHarness> _pumpActivitiesHarness(WidgetTester tester, _ControlledActivitiesClient transport) async { final serverId = ServerId('plex-server'); final client = testPlexClient(serverId: serverId, serverName: 'Test server', httpClient: transport); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServer(clients: [client]).provider; final buttonKey = GlobalKey(); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); - await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 4fe8602a..69a93be1 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -17,32 +17,13 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; MediaLibrary _library({ required String id, @@ -124,7 +105,7 @@ Future _pumpBasicRail( ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: height == null ? rail : SizedBox(height: height, child: rail), ), @@ -172,7 +153,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -237,7 +218,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -263,7 +244,7 @@ void main() { await _pumpBasicRail(tester, alwaysExpanded: true); final selectedItem = find.byType(NavigationRailItem).first; - expect(_railItemDecoration(tester, selectedItem)?.color, _testTokens.text.withValues(alpha: 0.1)); + expect(_railItemDecoration(tester, selectedItem)?.color, testMonoTokens.text.withValues(alpha: 0.1)); }); testWidgets('D-pad sidebar focus hides selected item background after focus moves', (tester) async { @@ -343,7 +324,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -408,7 +389,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( key: sideNavKey, @@ -485,7 +466,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( key: sideNavKey, @@ -527,7 +508,7 @@ void main() { await tester.pumpWidget( InputModeTracker( child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: Builder( builder: (context) { diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 44a2b0a1..7bead78f 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -8,29 +8,11 @@ import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/playback_subtitle_resolver.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -175,7 +157,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Builder( builder: (context) { @@ -399,7 +381,7 @@ Future _pumpTrackSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: SizedBox( diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 2c428548..227ca24a 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/services/playback_subtitle_resolver.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/video_volume_controller.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; import 'package:plezy/widgets/video_controls/desktop_video_controls.dart'; import 'package:plezy/widgets/video_controls/mobile_video_controls.dart'; @@ -36,25 +35,7 @@ import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart'; import '../test_helpers/watch_together_fakes.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -968,7 +949,7 @@ void main() { ChangeNotifierProvider.value( value: watchTogether, child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 1000, @@ -1023,7 +1004,7 @@ void main() { ChangeNotifierProvider.value( value: watchTogether, child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 500, @@ -1514,7 +1495,7 @@ void main() { ChangeNotifierProvider.value(value: watchTogether), ], child: MaterialApp( - theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [_testTokens]), + theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 1200, @@ -1568,7 +1549,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1604,7 +1585,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1650,7 +1631,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1703,7 +1684,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1755,7 +1736,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1865,7 +1846,7 @@ Future _pumpSkipMarkerButton( }) { return tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: Center( child: SkipMarkerButton( diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index e45e9965..babdd7e1 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -10,29 +10,10 @@ import 'package:plezy/mpv/player/player_streams.dart'; import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 4, - radiusMd: 8, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 100), - normal: Duration(milliseconds: 200), - slow: Duration(milliseconds: 300), - expressive: Duration(milliseconds: 300), - bg: Colors.black, - surface: Color(0xFF111111), - outline: Color(0xFF333333), - text: Colors.white, - textMuted: Color(0xFFAAAAAA), - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { setUpAll(() async { @@ -82,7 +63,7 @@ void main() { LocaleSettings.setLocaleSync(AppLocale.ru); await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: const SubtitleStylingScreen(), ), ); @@ -159,7 +140,7 @@ Future _pumpSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: Scaffold( body: SizedBox( width: 900,