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.
This commit is contained in:
edde746
2026-07-26 06:09:47 +02:00
parent 04d8070fd4
commit 61344f7862
34 changed files with 1599 additions and 2505 deletions
+11 -11
View File
@@ -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,
@@ -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<void> 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();
}
}
+54 -143
View File
@@ -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<PlexAccountConnection>());
await profileConnections.setDefault(profile.id, jellyfin.id);
await stack.profileConnections.setDefault(profile.id, jellyfin.id);
expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA<JellyfinConnection>());
});
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 = <http.Request>[];
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<http.Request> 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<http.Request> 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 = <http.Request>[];
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<void> dispose() async {
provider.dispose();
auth.dispose();
await active.resetForTesting();
active.dispose();
await plexHome.dispose();
await db.close();
await stack.dispose();
}
}
@@ -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<void> _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<MultiServerProvider>.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<void> _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 {
@@ -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<MediaItem>(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<void> _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<MultiServerProvider>.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<MediaLibrary>(
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<MediaLibrary>(
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<void> _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<void> _pumpUntil(WidgetTester tester, bool Function() condition) async {
@@ -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<void> _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<MultiServerProvider>.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() {
@@ -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<void> _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<MultiServerProvider>.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<int>(
valueListenable: harness.rebuild,
builder: (context, _, _) =>
LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack),
),
),
),
),
),
),
await pumpLibraryTab(
tester,
provider: harness.provider,
tab: ValueListenableBuilder<int>(
valueListenable: harness.rebuild,
builder: (context, _, _) => LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack),
),
size: const Size(800, 600),
focusSidebar: onSidebar,
);
await tester.pumpAndSettle();
}
+2 -6
View File
@@ -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) {
+2 -2
View File
@@ -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<State<SearchScreen>>)> _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<State<SearchScreen>>();
@@ -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<String, Object?> _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()] : <Object>[],
@@ -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 <String, Object?>{});
return jsonResponse(const <String, Object?>{});
}),
),
);
@@ -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 <String, Object?>{});
return jsonResponse(const <String, Object?>{});
}),
),
);
@@ -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);
}),
),
);
@@ -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);
+14 -29
View File
@@ -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<void> 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<void> 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<void> 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
File diff suppressed because it is too large Load Diff
+14 -44
View File
@@ -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 = <String>[];
final startGate = Completer<void>();
@override
Future<void> 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<void> 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<void> 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<void> 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
@@ -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<RecordedReport> reports = [];
final List<String> markedWatched = [];
Completer<List<MediaItem>>? instantMixGate;
@@ -381,47 +381,13 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
}
@override
Future<void> 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<void> 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<void> 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<void> 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));
}
}
@@ -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<void> 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<void> 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<void> 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
@@ -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<void> 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<void> 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<void> 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<void> 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<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {}
@override
Future<void> 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<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? liveStreamId,
String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {}
Future<void> onPlaybackReport(PlaybackReportCall call) async {}
@override
Future<void> markWatched(MediaItem item) async {
+21 -51
View File
@@ -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 = <String>[];
Completer<void>? startGate;
Completer<void>? stopGate;
bool failNextStop = false;
@override
Future<void> 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<void> 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<void> 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<void> 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
+7
View File
@@ -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'});
@@ -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<void> 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<MultiServerProvider>.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<void> pumpRequestFrames(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 500));
}
@@ -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<MediaServerClient> clients = const [],
List<MediaServerClient> 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);
}
@@ -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<void> onPlaybackReport(PlaybackReportCall call);
Future<void> 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<void> 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<void> 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,
),
);
}
}
+74
View File
@@ -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<ProfileStack> create({
AppDatabase? db,
List<PlexHomeUser> 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<void> dispose() async {
await active.resetForTesting();
active.dispose();
await plexHome.dispose();
if (_ownsDatabase) await db.close();
}
}
+46
View File
@@ -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,
);
+7 -25
View File
@@ -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 <String?>[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));
+2 -20
View File
@@ -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<void> _pumpSheet(
}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_tokens]),
theme: ThemeData(extensions: const [testMonoTokens]),
home: OverlaySheetHost(
child: Scaffold(
body: Builder(
+16 -85
View File
@@ -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<MediaContextMenuState>();
@@ -164,7 +147,7 @@ void main() {
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<MusicPlaybackService>.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<MediaContextMenuState>();
@@ -281,7 +250,7 @@ void main() {
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<PlaybackStateProvider>.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<MediaContextMenuState>();
@@ -360,7 +315,7 @@ void main() {
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
],
child: MaterialApp(
theme: monoTheme(dark: true),
@@ -615,21 +570,9 @@ Future<GlobalKey<MediaContextMenuState>> _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<GlobalKey<MediaContextMenuState>> _pumpPlexMovieMenu(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<ActiveProfileProvider>.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<NavigatorState>();
final profileNavigatorKey = GlobalKey<NavigatorState>();
@@ -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<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: stack.active),
ChangeNotifierProvider<MusicPlaybackService>.value(value: music),
],
child: ProfileNavigationScope(
+4 -23
View File
@@ -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,
),
);
+2 -21
View File
@@ -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)),
),
),
@@ -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<ServerActivitiesButtonState>();
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.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<ServerActivitiesButtonState>();
addTearDown(() {
multiServerProvider.dispose();
manager.dispose();
});
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.value(
+9 -28
View File
@@ -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<void> _pumpBasicRail(
ChangeNotifierProvider<MultiServerProvider>.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<MultiServerProvider>.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<MultiServerProvider>.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<MultiServerProvider>.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<MultiServerProvider>.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<MultiServerProvider>.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) {
+3 -21
View File
@@ -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<void> _pumpTrackSheet(
}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
theme: ThemeData(extensions: const [testMonoTokens]),
home: OverlaySheetHost(
child: Scaffold(
body: SizedBox(
+10 -29
View File
@@ -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<WatchTogetherProvider>.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<WatchTogetherProvider>.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<WatchTogetherProvider>.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<void> _pumpSkipMarkerButton(
}) {
return tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
theme: ThemeData(extensions: const [testMonoTokens]),
home: Scaffold(
body: Center(
child: SkipMarkerButton(
+3 -22
View File
@@ -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<void> _pumpSheet(
}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
theme: ThemeData(extensions: const [testMonoTokensAnimated]),
home: Scaffold(
body: SizedBox(
width: 900,