fix(tv): stabilize browse rail focus and loading

This commit is contained in:
edde746
2026-05-21 18:17:00 +02:00
parent 743a8668ec
commit eaf0f4fa1a
4 changed files with 491 additions and 6 deletions
+189
View File
@@ -0,0 +1,189 @@
import 'package:drift/native.dart';
import 'package:flutter/material.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/focus/focusable_action_bar.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.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/server_capabilities.dart';
import 'package:plezy/mixins/refreshable.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/providers/hidden_libraries_provider.dart';
import 'package:plezy/providers/libraries_provider.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/screens/discover_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/services/storage_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/watch_together/watch_together.dart';
import 'package:plezy/widgets/tv_browse_rail.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
TvDetectionService.debugSetAppleTVOverride(true);
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async {
await SettingsService.getInstance();
final item = MediaItem(
id: 'movie_1',
backend: MediaBackend.plex,
kind: MediaKind.movie,
title: 'Movie 1',
serverId: 'server_1',
serverName: 'Server',
);
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1);
final client = _FakeMediaServerClient(hubs: [hub]);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
final hiddenLibrariesProvider = HiddenLibrariesProvider();
final librariesProvider = LibrariesProvider();
final watchTogetherProvider = WatchTogetherProvider();
final companionRemoteProvider = CompanionRemoteProvider();
final db = AppDatabase.forTesting(NativeDatabase.memory());
final profileRegistry = _FakeProfileRegistry(db);
final connectionRegistry = _FakeConnectionRegistry(db);
final profileConnectionRegistry = _FakeProfileConnectionRegistry(db);
final storage = await StorageService.getInstance();
final plexHome = PlexHomeService(
connections: connectionRegistry,
profileConnections: profileConnectionRegistry,
storage: storage,
plexHomeUserFetcher: (_) async => const [],
);
final activeProfileProvider = ActiveProfileProvider(
registry: profileRegistry,
plexHome: plexHome,
connections: connectionRegistry,
storage: storage,
);
final discoverKey = GlobalKey<State<DiscoverScreen>>();
addTearDown(() async {
activeProfileProvider.dispose();
companionRemoteProvider.dispose();
watchTogetherProvider.dispose();
librariesProvider.dispose();
hiddenLibrariesProvider.dispose();
multiServerProvider.dispose();
await plexHome.dispose();
await db.close();
});
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
],
child: MaterialApp(
theme: monoTheme(dark: true),
home: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(TvBrowseRail), findsOneWidget);
tester.state<FocusableActionBarState>(find.byType(FocusableActionBar)).requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
(discoverKey.currentState! as FocusableTab).focusActiveTabIfReady();
await tester.pump();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
});
}
class _FakeMediaServerClient implements MediaServerClient {
final List<MediaHub> hubs;
_FakeMediaServerClient({required this.hubs});
@override
String get serverId => 'server_1';
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex;
@override
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => const [];
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async => hubs;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeProfileRegistry extends ProfileRegistry {
_FakeProfileRegistry(super.db);
@override
Stream<List<Profile>> watchProfiles() => Stream.value(const []);
@override
Future<List<Profile>> list() async => const [];
}
class _FakeConnectionRegistry extends ConnectionRegistry {
_FakeConnectionRegistry(super.db);
@override
Stream<List<Connection>> watchConnections() => Stream.value(const []);
@override
Future<List<Connection>> list() async => const [];
}
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
_FakeProfileConnectionRegistry(super.db);
@override
Stream<List<ProfileConnection>> watchAll() => Stream.value(const []);
}
+181
View File
@@ -0,0 +1,181 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_hub.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/providers/multi_server_provider.dart';
import 'package:plezy/screens/media_detail_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';
import 'package:plezy/utils/media_server_http_client.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:provider/provider.dart';
import '../test_helpers/prefs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
TvDetectionService.debugSetAppleTVOverride(true);
LocaleSettings.setLocaleSync(AppLocale.en);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('TV detail reveals season hubs together after all episode caches load', (tester) async {
await SettingsService.getInstance();
final show = MediaItem(
id: 'show_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.show,
title: 'The Show',
serverId: 'server_1',
serverName: 'Server',
);
final season1 = MediaItem(
id: 'season_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
title: 'Season 1',
index: 1,
parentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final season2 = MediaItem(
id: 'season_2',
backend: MediaBackend.jellyfin,
kind: MediaKind.season,
title: 'Season 2',
index: 2,
parentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final episode1 = MediaItem(
id: 'episode_1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'Episode 1',
index: 1,
parentId: season1.id,
parentIndex: season1.index,
grandparentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final episode2 = MediaItem(
id: 'episode_2',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
title: 'Episode 2',
index: 1,
parentId: season2.id,
parentIndex: season2.index,
grandparentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final descendantsCompleter = Completer<List<MediaItem>>();
final client = _FakeMediaServerClient(
show: show,
childrenByParent: {
show.id: [season1, season2],
},
pendingPlayableDescendants: descendantsCompleter.future,
);
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
final provider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(provider.dispose);
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)),
),
),
),
);
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Season 1'), findsNothing);
expect(find.text('Season 2'), findsNothing);
descendantsCompleter.complete([episode1, episode2]);
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('Season 2'), findsOneWidget);
});
}
class _FakeMediaServerClient implements MediaServerClient {
final MediaItem show;
final Map<String, List<MediaItem>> childrenByParent;
final Future<List<MediaItem>> pendingPlayableDescendants;
_FakeMediaServerClient({
required this.show,
required this.childrenByParent,
required this.pendingPlayableDescendants,
});
@override
String get serverId => 'server_1';
@override
String? get serverName => 'Server';
@override
MediaBackend get backend => MediaBackend.jellyfin;
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
return (item: show, onDeckEpisode: null);
}
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
return childrenByParent[parentId] ?? const [];
}
@override
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
String parentId, {
int? start,
int? size,
AbortController? abort,
}) async {
final items = await pendingPlayableDescendants;
return LibraryPage(items: items, totalCount: items.length, offset: start ?? 0);
}
@override
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async => const [];
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}